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/bun.lock b/bun.lock index 115c02a19e..247c12614c 100644 --- a/bun.lock +++ b/bun.lock @@ -228,6 +228,7 @@ "@kilocode/kilo-ui": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@thisbeyond/solid-dnd": "0.7.5", "diff": "^7.0.0", "dotenv": "^16.4.7", "eventsource": "^2.0.2", 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": { diff --git a/flake.nix b/flake.nix index f6da887f5d..73ed40aead 100644 --- a/flake.nix +++ b/flake.nix @@ -162,6 +162,7 @@ unzip gnutar gzip + ripgrep kilo-dev kilo-install-bin kilo-bin diff --git a/nix/hashes.json b/nix/hashes.json index 0cc570933c..d679b783ee 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-LVuT8+lBSeogRYQQHruUJJBeSRw9jd3arXy1GLzg9JM=", - "aarch64-linux": "sha256-QDTbTB4Lcc9u5MdEdZZ07GBBbLQkcl2NrwvxRc5WMsk=", - "aarch64-darwin": "sha256-skuEhDRrT585MJL58SpPOqnz0ivrlzShCRkA2f/YIxU=", - "x86_64-darwin": "sha256-Ocf8Mq9dgniaUlUAs0D/ygggh9ozVr8FydkgDsCsEPI=" + "x86_64-linux": "sha256-V1SML9naH7HShbdovoQIUc46rH6YNgFF9MSTw95k0fU=", + "aarch64-linux": "sha256-wddgxfbK7v7O/zsdbx0e/TNYAw0KzzxyQbuUdwudf/Y=", + "aarch64-darwin": "sha256-y7bMXWMSiHTHfKKiJ+c/2WRJmc7mCw2o601Ny8W5Zzs=", + "x86_64-darwin": "sha256-hXdKlmVFWq2C0kJWgO1+g82B+/fvn9CG/JQyR31QtzY=" } } 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: 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/kilo-vscode/package.json b/packages/kilo-vscode/package.json index f2fee4118a..3b90ed2825 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", @@ -535,6 +536,7 @@ "@kilocode/kilo-ui": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@thisbeyond/solid-dnd": "0.7.5", "diff": "^7.0.0", "dotenv": "^16.4.7", "eventsource": "^2.0.2", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a5dabc9d98..a0171af1ab 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,10 +1,23 @@ 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" 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" @@ -22,6 +35,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper 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). */ @@ -37,6 +52,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper constructor( private readonly extensionUri: vscode.Uri, private readonly connectionService: KiloConnectionService, + private readonly extensionContext?: vscode.ExtensionContext, ) { TelemetryProxy.getInstance().setProvider(this) } @@ -402,6 +418,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper 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 @@ -491,6 +513,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.fetchAndSendProviders() await this.fetchAndSendAgents() await this.fetchAndSendConfig() + await this.fetchAndSendNotifications() this.sendNotificationSettings() console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") @@ -505,16 +528,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 +801,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 +836,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", @@ -875,6 +882,46 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * 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 existing = this.extensionContext?.globalState.get("kilo.dismissedNotificationIds", []) ?? [] + const active = new Set(notifications.map((n) => n.id)) + const dismissedIds = existing.filter((id) => active.has(id)) + if (dismissedIds.length !== existing.length) { + await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", dismissedIds) + } + 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. */ @@ -1256,9 +1303,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 +1387,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/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index c91f801fa1..c1eba76d2a 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,7 +4,10 @@ import { KiloProvider } from "../KiloProvider" import { buildWebviewHtml } from "../utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager } from "./WorktreeStateManager" +import { SetupScriptService } from "./SetupScriptService" +import { SetupScriptRunner } from "./SetupScriptRunner" import { SessionTerminalManager } from "./SessionTerminalManager" +import { formatKeybinding } from "./format-keybinding" /** * AgentManagerProvider opens the Agent Manager panel. @@ -22,7 +25,9 @@ export class AgentManagerProvider implements vscode.Disposable { private outputChannel: vscode.OutputChannel private worktrees: WorktreeManager | undefined private state: WorktreeStateManager | undefined + private setupScript: SetupScriptService | undefined private terminalManager: SessionTerminalManager + private stateReady: Promise | undefined constructor( private readonly extensionUri: vscode.Uri, @@ -70,8 +75,9 @@ export class AgentManagerProvider implements vscode.Disposable { onBeforeMessage: (msg) => this.onMessage(msg), }) - void this.initializeState() + this.stateReady = this.initializeState() void this.sendRepoInfo() + this.sendKeybindings() this.panel.onDidDispose(() => { this.log("Panel disposed") @@ -88,7 +94,10 @@ export class AgentManagerProvider implements vscode.Disposable { private async initializeState(): Promise { const manager = this.getWorktreeManager() const state = this.getStateManager() - if (!manager || !state) return + if (!manager || !state) { + this.pushEmptyState() + return + } await state.load() @@ -129,6 +138,10 @@ export class AgentManagerProvider implements vscode.Disposable { return this.onAddSessionToWorktree(msg.worktreeId) if (type === "agentManager.closeSession" && typeof msg.sessionId === "string") return this.onCloseSession(msg.sessionId) + if (type === "agentManager.configureSetupScript") { + void this.configureSetupScript() + return null + } if (type === "agentManager.showTerminal" && typeof msg.sessionId === "string") { this.terminalManager.showTerminal(msg.sessionId, this.state) return null @@ -137,6 +150,27 @@ export class AgentManagerProvider implements vscode.Disposable { void this.sendRepoInfo() return null } + if (type === "agentManager.createMultiVersion") { + void this.onCreateMultiVersion(msg) + 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 + } + if (type === "agentManager.setSessionsCollapsed" && typeof msg.collapsed === "boolean") { + this.state?.setSessionsCollapsed(msg.collapsed as boolean) + return null + } // When switching sessions, show existing terminal if one is open if (type === "loadMessages" && typeof msg.sessionID === "string") { @@ -161,7 +195,7 @@ export class AgentManagerProvider implements vscode.Disposable { // --------------------------------------------------------------------------- /** Create a git worktree on disk and register it in state. Returns null on failure. */ - private async createWorktreeOnDisk(): Promise<{ + private async createWorktreeOnDisk(groupId?: string): Promise<{ worktree: ReturnType result: CreateWorktreeResult } | null> { @@ -191,7 +225,12 @@ export class AgentManagerProvider implements vscode.Disposable { return null } - const worktree = state.addWorktree({ branch: result.branch, path: result.path, parentBranch: result.parentBranch }) + const worktree = state.addWorktree({ + branch: result.branch, + path: result.path, + parentBranch: result.parentBranch, + groupId, + }) return { worktree, result } } @@ -258,6 +297,9 @@ export class AgentManagerProvider implements vscode.Disposable { const created = await this.createWorktreeOnDisk() if (!created) return null + // Run setup script for new worktree (blocks until complete, shows in overlay) + await this.runSetupScriptForWorktree(created.result.path, created.result.branch) + const session = await this.createSessionInWorktree(created.result.path, created.result.branch) if (!session) { const state = this.getStateManager() @@ -307,6 +349,9 @@ export class AgentManagerProvider implements vscode.Disposable { const created = await this.createWorktreeOnDisk() if (!created) return null + // Run setup script for new worktree (blocks until complete, shows in overlay) + await this.runSetupScriptForWorktree(created.result.path, created.result.branch) + const state = this.getStateManager()! if (!state.getSession(sessionId)) { state.addSession(sessionId, created.worktree.id) @@ -376,6 +421,209 @@ export class AgentManagerProvider implements vscode.Disposable { return null } + // --------------------------------------------------------------------------- + // Multi-version worktree creation + // --------------------------------------------------------------------------- + + /** Create N worktree sessions for the same prompt (multi-version mode). */ + private async onCreateMultiVersion(msg: Record): Promise { + const text = msg.text as string + if (!text) return null + + const versions = Math.min(Math.max(Number(msg.versions) || 1, 1), 4) + const providerID = msg.providerID as string | undefined + const modelID = msg.modelID as string | undefined + const agent = msg.agent as string | undefined + const files = msg.files as Array<{ mime: string; url: string }> | undefined + + // Generate a shared group ID for multi-version worktrees + const groupId = versions > 1 ? `grp-${Date.now()}` : undefined + + this.log( + `Creating ${versions} multi-version worktrees for: ${text.slice(0, 60)}${groupId ? ` (group=${groupId})` : ""}`, + ) + + // Notify webview that multi-version creation has started + this.postToWebview({ + type: "agentManager.multiVersionProgress", + status: "creating", + total: versions, + completed: 0, + groupId, + }) + + // Phase 1: Create all worktrees + sessions first + const created: Array<{ + worktreeId: string + sessionId: string + path: string + branch: string + parentBranch: string + }> = [] + + for (let i = 0; i < versions; i++) { + this.log(`Creating worktree ${i + 1}/${versions}`) + + const wt = await this.createWorktreeOnDisk(groupId) + if (!wt) { + this.log(`Failed to create worktree for version ${i + 1}`) + continue + } + + await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch) + + const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch) + if (!session) { + const state = this.getStateManager() + const manager = this.getWorktreeManager() + state?.removeWorktree(wt.worktree.id) + await manager?.removeWorktree(wt.result.path) + this.log(`Failed to create session for version ${i + 1}`) + continue + } + + const state = this.getStateManager()! + state.addSession(session.id, wt.worktree.id) + this.registerWorktreeSession(session.id, wt.result.path) + this.notifyWorktreeReady(session.id, wt.result) + + created.push({ + worktreeId: wt.worktree.id, + sessionId: session.id, + path: wt.result.path, + branch: wt.result.branch, + parentBranch: wt.result.parentBranch, + }) + + this.log(`Version ${i + 1} worktree ready: session=${session.id}`) + + // Update progress + this.postToWebview({ + type: "agentManager.multiVersionProgress", + status: "creating", + total: versions, + completed: created.length, + groupId, + }) + } + + // Phase 2: Send the initial prompt to all sessions via the KiloProvider's + // message handling (same path as typing in the chat). This ensures SSE + // subscriptions and session tracking are properly set up before the message + // is sent. We route each message through the webview→KiloProvider pipeline. + for (let i = 0; i < created.length; i++) { + const entry = created[i]! + this.log(`Sending initial message to version ${i + 1} (session=${entry.sessionId})`) + + // Tell the webview to send the message through the normal session flow + this.postToWebview({ + type: "agentManager.sendInitialMessage", + sessionId: entry.sessionId, + worktreeId: entry.worktreeId, + text, + providerID, + modelID, + agent, + files, + }) + + // Small delay between sends to avoid overwhelming the backend + if (i < created.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 300)) + } + } + + // Notify completion + this.postToWebview({ + type: "agentManager.multiVersionProgress", + status: "done", + total: versions, + completed: created.length, + groupId, + }) + + if (created.length === 0) { + vscode.window.showErrorMessage(`Failed to create any of the ${versions} multi-version worktrees.`) + } + + this.log(`Multi-version creation complete: ${created.length}/${versions} versions`) + return null + } + + // --------------------------------------------------------------------------- + // Keybindings + // --------------------------------------------------------------------------- + + private sendKeybindings(): void { + const ext = vscode.extensions.getExtension("kilocode.kilo-code") + const keybindings: Array<{ command: string; key?: string; mac?: string }> = + ext?.packageJSON?.contributes?.keybindings ?? [] + + const mac = process.platform === "darwin" + const prefix = "kilo-code.new.agentManager." + const bindings: Record = {} + + // Global keybindings exposed to the shortcuts dialog + const globals: Record = { + "kilo-code.new.agentManagerOpen": "agentManagerOpen", + } + + for (const kb of keybindings) { + const raw = mac ? (kb.mac ?? kb.key) : kb.key + if (!raw) continue + + if (kb.command.startsWith(prefix)) { + bindings[kb.command.slice(prefix.length)] = formatKeybinding(raw, mac) + } else if (globals[kb.command]) { + bindings[globals[kb.command]] = formatKeybinding(raw, mac) + } + } + + this.postToWebview({ type: "agentManager.keybindings", bindings }) + } + + // --------------------------------------------------------------------------- + // Setup script + // --------------------------------------------------------------------------- + + /** Open the worktree setup script in the editor for user configuration. */ + private async configureSetupScript(): Promise { + const service = this.getSetupScriptService() + if (!service) return + try { + await service.openInEditor() + } catch (error) { + this.log(`Failed to open setup script: ${error}`) + } + } + + /** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */ + private async runSetupScriptForWorktree(worktreePath: string, branch?: string): Promise { + const root = this.getWorkspaceRoot() + if (!root) return + try { + const service = this.getSetupScriptService() + if (!service || !service.hasScript()) return + this.postToWebview({ + type: "agentManager.worktreeSetup", + status: "creating", + message: "Running setup script...", + branch, + }) + const runner = new SetupScriptRunner(this.outputChannel, service) + await runner.runIfConfigured({ worktreePath, repoPath: root }) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`) + this.postToWebview({ + type: "agentManager.worktreeSetup", + status: "error", + message: `Setup script failed: ${msg}`, + branch, + }) + } + } + // --------------------------------------------------------------------------- // Repo info // --------------------------------------------------------------------------- @@ -408,6 +656,19 @@ export class AgentManagerProvider implements vscode.Disposable { type: "agentManager.state", worktrees: state.getWorktrees(), sessions: state.getSessions(), + tabOrder: state.getTabOrder(), + sessionsCollapsed: state.getSessionsCollapsed(), + isGitRepo: true, + }) + } + + /** Push empty state when the workspace is not a git repo or has no workspace folder. */ + private pushEmptyState(): void { + this.postToWebview({ + type: "agentManager.state", + worktrees: [], + sessions: [], + isGitRepo: false, }) } @@ -443,6 +704,17 @@ export class AgentManagerProvider implements vscode.Disposable { return this.state } + private getSetupScriptService(): SetupScriptService | undefined { + if (this.setupScript) return this.setupScript + const root = this.getWorkspaceRoot() + if (!root) { + this.log("getSetupScriptService: no workspace folder available") + return undefined + } + this.setupScript = new SetupScriptService(root) + return this.setupScript + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -478,6 +750,10 @@ export class AgentManagerProvider implements vscode.Disposable { this.panel.reveal(vscode.ViewColumn.One, false) } + public isActive(): boolean { + return this.panel?.active === true + } + public postMessage(message: unknown): void { this.panel?.webview.postMessage(message) } diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts new file mode 100644 index 0000000000..3703c4bb6c --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -0,0 +1,152 @@ +/** + * SetupScriptRunner - Executes worktree setup scripts + * + * Runs setup scripts in VS Code integrated terminal before agent starts. + * Uses VS Code shell integration to track execution and exit code. + * Falls back to sendText + onDidCloseTerminal if shell integration is unavailable. + * Cross-platform: Unix uses sh, Windows uses cmd.exe. + */ + +import * as vscode from "vscode" +import { SetupScriptService } from "./SetupScriptService" +import { buildSetupCommand } from "./setup-script-command" + +export interface SetupScriptEnvironment { + /** Absolute path to the worktree directory */ + worktreePath: string + /** Absolute path to the main repository */ + repoPath: string +} + +export class SetupScriptRunner { + constructor( + private readonly output: vscode.OutputChannel, + private readonly service: SetupScriptService, + ) {} + + /** + * Execute setup script in a worktree if script exists. + * Waits for the script to finish before resolving. + * + * @returns true if script was executed, false if skipped (no script configured) + */ + async runIfConfigured(env: SetupScriptEnvironment): Promise { + if (!this.service.hasScript()) { + this.log("No setup script configured, skipping") + return false + } + + const script = this.service.getScriptPath() + this.log(`Running setup script: ${script}`) + + try { + await this.executeInTerminal(script, env) + this.log("Setup script completed") + return true + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + this.log(`Setup script execution failed: ${msg}`) + return true // Script was attempted + } + } + + /** Execute the setup script in a VS Code terminal and wait for it to finish. */ + private async executeInTerminal(script: string, env: SetupScriptEnvironment): Promise { + const terminal = vscode.window.createTerminal({ + name: "Worktree Setup", + cwd: env.worktreePath, + env: { + WORKTREE_PATH: env.worktreePath, + REPO_PATH: env.repoPath, + }, + iconPath: new vscode.ThemeIcon("gear"), + }) + + terminal.show(true) + + // Try shell integration first — gives us proper exit code tracking + const integration = await this.waitForShellIntegration(terminal, 5000) + if (integration) { + this.log("Using shell integration for setup script execution") + await this.runViaShellIntegration(terminal, integration, script, env) + } else { + this.log("Shell integration unavailable, falling back to sendText") + await this.runViaSendText(terminal, script, env) + } + } + + /** Wait for shell integration to become available on a terminal, with timeout. */ + private waitForShellIntegration( + terminal: vscode.Terminal, + timeout: number, + ): Promise { + if (terminal.shellIntegration) return Promise.resolve(terminal.shellIntegration) + + return new Promise((resolve) => { + const timer = setTimeout(() => { + listener.dispose() + resolve(undefined) + }, timeout) + + const listener = vscode.window.onDidChangeTerminalShellIntegration((e) => { + if (e.terminal !== terminal) return + clearTimeout(timer) + listener.dispose() + resolve(e.shellIntegration) + }) + }) + } + + /** Run script via shell integration — tracks execution and exit code properly. */ + private runViaShellIntegration( + terminal: vscode.Terminal, + integration: vscode.TerminalShellIntegration, + script: string, + env: SetupScriptEnvironment, + ): Promise { + return new Promise((resolve) => { + const command = buildSetupCommand(script, env) + const execution = integration.executeCommand(command) + + const cleanup = () => { + execListener.dispose() + closeListener.dispose() + } + + // Primary: shell integration reports execution finished with exit code + const execListener = vscode.window.onDidEndTerminalShellExecution((e) => { + if (e.execution !== execution) return + cleanup() + this.log(`Setup script exited with code ${e.exitCode ?? "unknown"}`) + resolve() + }) + + // Fallback: terminal was closed externally (user, VS Code restart, etc.) + const closeListener = vscode.window.onDidCloseTerminal((closed) => { + if (closed !== terminal) return + cleanup() + this.log("Setup script terminal closed before execution event fired") + resolve() + }) + }) + } + + /** Fallback: run via sendText and wait for terminal to close. */ + private runViaSendText(terminal: vscode.Terminal, script: string, env: SetupScriptEnvironment): Promise { + return new Promise((resolve) => { + const listener = vscode.window.onDidCloseTerminal((closed) => { + if (closed !== terminal) return + listener.dispose() + resolve() + }) + + const command = buildSetupCommand(script, env) + (process.platform === "win32" ? "& exit" : "; exit") + terminal.sendText(command) + this.log("Setup script started in terminal, waiting for completion...") + }) + } + + private log(message: string): void { + this.output.appendLine(`[SetupScriptRunner] ${message}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts new file mode 100644 index 0000000000..26f08eec22 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts @@ -0,0 +1,68 @@ +/** + * SetupScriptService - Manages worktree setup scripts + * + * Handles reading, creating, and checking for setup scripts stored in .kilocode/setup-script. + * Setup scripts run before an agent starts in a worktree (new sessions only). + */ + +import * as vscode from "vscode" +import * as fs from "node:fs" +import * as path from "node:path" +import { SETUP_SCRIPT_TEMPLATE } from "./setup-script-template" + +const SETUP_SCRIPT_FILENAME = "setup-script" +const KILOCODE_DIR = ".kilocode" + +export class SetupScriptService { + private readonly root: string + private readonly script: string + + constructor(root: string) { + this.root = root + this.script = path.join(root, KILOCODE_DIR, SETUP_SCRIPT_FILENAME) + } + + /** Get the path to the setup script */ + getScriptPath(): string { + return this.script + } + + /** Check if a setup script exists */ + hasScript(): boolean { + return fs.existsSync(this.script) + } + + /** Read the setup script content. Returns null if not found or read fails. */ + async getScript(): Promise { + if (!this.hasScript()) return null + try { + return await fs.promises.readFile(this.script, "utf-8") + } catch (error) { + this.log(`Failed to read setup script: ${error}`) + return null + } + } + + /** Create a default setup script with helpful comments */ + async createDefaultScript(): Promise { + const dir = path.join(this.root, KILOCODE_DIR) + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }) + } + await fs.promises.writeFile(this.script, SETUP_SCRIPT_TEMPLATE, "utf-8") + } + + /** Open the setup script in VS Code editor. Creates the default script if it doesn't exist. */ + async openInEditor(): Promise { + if (!this.hasScript()) { + await this.createDefaultScript() + } + const document = await vscode.workspace.openTextDocument(this.script) + await vscode.window.showTextDocument(document) + } + + private log(message: string): void { + // Log to console since we don't have an OutputChannel here + console.log(`[SetupScriptService] ${message}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 8f3c203f26..28df15009d 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -185,6 +185,7 @@ export class WorktreeManager { const excludePath = path.join(gitDir, "info", "exclude") await this.addExcludeEntry(excludePath, ".kilocode/worktrees/", "Kilo Code agent worktrees") await this.addExcludeEntry(excludePath, ".kilocode/agent-manager.json", "Kilo Agent Manager state") + await this.addExcludeEntry(excludePath, ".kilocode/setup-script", "Kilo Code worktree setup script") } private async ensureWorktreeExclude(worktreePath: string): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index b36b79d7d7..5e3fb33312 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -18,6 +18,8 @@ export interface Worktree { path: string parentBranch: string createdAt: string + /** Shared identifier for worktrees created together via multi-version mode. */ + groupId?: string } export interface ManagedSession { @@ -29,6 +31,8 @@ export interface ManagedSession { interface StateFile { worktrees: Record> sessions: Record> + tabOrder?: Record + sessionsCollapsed?: boolean } const STATE_FILE = "agent-manager.json" @@ -44,6 +48,8 @@ export class WorktreeStateManager { private readonly file: string private worktrees = new Map() private sessions = new Map() + private tabOrder: Record = {} + private collapsed = false private readonly log: (msg: string) => void private saving: Promise | undefined private pendingSave = false @@ -103,11 +109,18 @@ export class WorktreeStateManager { // Mutations // --------------------------------------------------------------------------- - addWorktree(params: { branch: string; path: string; parentBranch: string }): Worktree { + addWorktree(params: { branch: string; path: string; parentBranch: string; groupId?: string }): Worktree { const id = generateId("wt") - const wt: Worktree = { id, ...params, createdAt: new Date().toISOString() } + const wt: Worktree = { + id, + branch: params.branch, + path: params.path, + parentBranch: params.parentBranch, + createdAt: new Date().toISOString(), + } + if (params.groupId) wt.groupId = params.groupId this.worktrees.set(id, wt) - this.log(`Added worktree ${id}: ${params.branch}`) + this.log(`Added worktree ${id}: ${params.branch}${params.groupId ? ` (group=${params.groupId})` : ""}`) void this.save() return wt } @@ -125,6 +138,9 @@ export class WorktreeStateManager { } } + // Clean up tab order for this worktree + delete this.tabOrder[id] + this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`) void this.save() return orphaned @@ -149,6 +165,47 @@ export class WorktreeStateManager { removeSession(id: string): void { this.sessions.delete(id) + + // Remove this session from any tab order arrays + for (const [key, order] of Object.entries(this.tabOrder)) { + const idx = order.indexOf(id) + if (idx !== -1) { + order.splice(idx, 1) + if (order.length === 0) delete this.tabOrder[key] + } + } + + void this.save() + } + + // --------------------------------------------------------------------------- + // Tab order + // --------------------------------------------------------------------------- + + getTabOrder(): Record { + return this.tabOrder + } + + setTabOrder(key: string, order: string[]): void { + this.tabOrder[key] = order + void this.save() + } + + removeTabOrder(key: string): void { + delete this.tabOrder[key] + void this.save() + } + + // --------------------------------------------------------------------------- + // Sessions collapsed + // --------------------------------------------------------------------------- + + getSessionsCollapsed(): boolean { + return this.collapsed + } + + setSessionsCollapsed(value: boolean): void { + this.collapsed = value void this.save() } @@ -162,6 +219,7 @@ export class WorktreeStateManager { const data = JSON.parse(content) as StateFile this.worktrees.clear() this.sessions.clear() + this.tabOrder = {} for (const [id, wt] of Object.entries(data.worktrees ?? {})) { this.worktrees.set(id, { id, ...wt }) @@ -169,6 +227,10 @@ export class WorktreeStateManager { for (const [id, s] of Object.entries(data.sessions ?? {})) { this.sessions.set(id, { id, ...s }) } + if (data.tabOrder) { + this.tabOrder = data.tabOrder + } + this.collapsed = data.sessionsCollapsed ?? false this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`) } catch (error) { const code = (error as NodeJS.ErrnoException).code @@ -229,6 +291,12 @@ export class WorktreeStateManager { const { id: _, ...rest } = s data.sessions[id] = rest } + if (Object.keys(this.tabOrder).length > 0) { + data.tabOrder = this.tabOrder + } + if (this.collapsed) { + data.sessionsCollapsed = true + } const dir = path.dirname(this.file) if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true }) diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts new file mode 100644 index 0000000000..ed69b1c37b --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest" +import { buildSetupCommand } from "../setup-script-command" + +const env = { + worktreePath: "/repos/project/.kilocode/worktrees/wt-1", + repoPath: "/repos/project", +} + +const script = "/repos/project/.kilocode/setup-script" + +describe("buildSetupCommand", () => { + it("builds unix command with inline env vars and sh", () => { + const result = buildSetupCommand(script, env, "darwin") + expect(result).toBe( + `WORKTREE_PATH="/repos/project/.kilocode/worktrees/wt-1" REPO_PATH="/repos/project" sh "/repos/project/.kilocode/setup-script"`, + ) + }) + + it("builds linux command same as darwin", () => { + const result = buildSetupCommand(script, env, "linux") + expect(result).toContain("sh ") + expect(result).not.toContain("set ") + expect(result).not.toContain("call ") + }) + + it("builds windows command with set and call", () => { + const result = buildSetupCommand(script, env, "win32") + expect(result).toBe( + `set "WORKTREE_PATH=/repos/project/.kilocode/worktrees/wt-1" && set "REPO_PATH=/repos/project" && call "/repos/project/.kilocode/setup-script"`, + ) + }) + + it("includes both env vars in unix command", () => { + const result = buildSetupCommand(script, env, "darwin") + expect(result).toContain(`WORKTREE_PATH="${env.worktreePath}"`) + expect(result).toContain(`REPO_PATH="${env.repoPath}"`) + }) + + it("includes both env vars in windows command", () => { + const result = buildSetupCommand(script, env, "win32") + expect(result).toContain(`set "WORKTREE_PATH=${env.worktreePath}"`) + expect(result).toContain(`set "REPO_PATH=${env.repoPath}"`) + }) + + it("handles paths with spaces", () => { + const spaced = { + worktreePath: "/Users/dev/my project/.kilocode/worktrees/wt-1", + repoPath: "/Users/dev/my project", + } + const spacedScript = "/Users/dev/my project/.kilocode/setup-script" + + const unix = buildSetupCommand(spacedScript, spaced, "darwin") + expect(unix).toContain(`sh "/Users/dev/my project/.kilocode/setup-script"`) + + const win = buildSetupCommand(spacedScript, spaced, "win32") + expect(win).toContain(`call "/Users/dev/my project/.kilocode/setup-script"`) + }) + + it("escapes double quotes in unix paths", () => { + const dangerous = { + worktreePath: '/repos/proj"ect', + repoPath: "/repos/safe", + } + const result = buildSetupCommand(script, dangerous, "darwin") + expect(result).toContain(`WORKTREE_PATH="/repos/proj\\"ect"`) + }) + + it("escapes dollar signs in unix paths", () => { + const dangerous = { + worktreePath: "/repos/$HOME/project", + repoPath: "/repos/safe", + } + const result = buildSetupCommand(script, dangerous, "darwin") + expect(result).toContain(`WORKTREE_PATH="/repos/\\$HOME/project"`) + }) + + it("escapes backticks in unix paths", () => { + const dangerous = { + worktreePath: "/repos/`whoami`/project", + repoPath: "/repos/safe", + } + const result = buildSetupCommand(script, dangerous, "darwin") + expect(result).toContain('WORKTREE_PATH="/repos/\\`whoami\\`/project"') + }) +}) diff --git a/packages/kilo-vscode/src/agent-manager/format-keybinding.ts b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts new file mode 100644 index 0000000000..e305ad2aae --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts @@ -0,0 +1,34 @@ +const KEY_SYMBOLS: Record = { + ctrl: { mac: "⌃", other: "Ctrl" }, + cmd: { mac: "⌘", other: "Ctrl" }, + shift: { mac: "⇧", other: "Shift" }, + alt: { mac: "⌥", other: "Alt" }, +} + +const SPECIAL_KEYS: Record = { + left: "←", + right: "→", + up: "↑", + down: "↓", + backspace: "⌫", + delete: "Del", + enter: "↵", + escape: "Esc", +} + +/** + * Format a VS Code keybinding string (e.g. "cmd+shift+w") into + * a display string using platform-appropriate symbols. + * Mac: "⌘⇧W" Windows/Linux: "Ctrl+Shift+W" + */ +export function formatKeybinding(raw: string, mac: boolean): string { + const symbols = raw + .split("+") + .map((p) => p.trim().toLowerCase()) + .map((part) => { + const mod = KEY_SYMBOLS[part] + if (mod) return mac ? mod.mac : mod.other + return SPECIAL_KEYS[part] ?? part.toUpperCase() + }) + return mac ? symbols.join("") : symbols.join("+") +} diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-command.ts b/packages/kilo-vscode/src/agent-manager/setup-script-command.ts new file mode 100644 index 0000000000..3d3dd14718 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-command.ts @@ -0,0 +1,21 @@ +/** Escape characters that are special inside double-quoted shell strings. */ +function escapeShell(value: string): string { + return value.replace(/["$`\\]/g, "\\$&") +} + +/** Build the platform-appropriate command string for running a setup script. */ +export function buildSetupCommand( + script: string, + env: { worktreePath: string; repoPath: string }, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === "win32") { + // Windows cmd.exe: double quotes in set values don't need escaping the same way, + // but we escape for the call argument + return `set "WORKTREE_PATH=${env.worktreePath}" && set "REPO_PATH=${env.repoPath}" && call "${script}"` + } + const wt = escapeShell(env.worktreePath) + const repo = escapeShell(env.repoPath) + const path = escapeShell(script) + return `WORKTREE_PATH="${wt}" REPO_PATH="${repo}" sh "${path}"` +} diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-template.ts b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts new file mode 100644 index 0000000000..8c59a63b6f --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts @@ -0,0 +1,41 @@ +/** Default template for worktree setup scripts. */ +export const SETUP_SCRIPT_TEMPLATE = `#!/bin/bash +# Kilo Code Worktree Setup Script +# This script runs before the agent starts in a worktree (new sessions only). +# +# Available environment variables: +# WORKTREE_PATH - Absolute path to the worktree directory +# REPO_PATH - Absolute path to the main repository +# +# Example tasks: +# - Copy .env files from main repo +# - Install dependencies +# - Run database migrations +# - Set up local configuration + +set -e # Exit on error + +echo "Setting up worktree: $WORKTREE_PATH" + +# Uncomment and modify as needed: + +# Copy environment files +# if [ -f "$REPO_PATH/.env" ]; then +# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env" +# echo "Copied .env" +# fi + +# Install dependencies (Node.js) +# if [ -f "$WORKTREE_PATH/package.json" ]; then +# cd "$WORKTREE_PATH" +# npm install +# fi + +# Install dependencies (Python) +# if [ -f "$WORKTREE_PATH/requirements.txt" ]; then +# cd "$WORKTREE_PATH" +# pip install -r requirements.txt +# fi + +echo "Setup complete!" +` diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index abe16739a4..51b8d2a59a 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -33,7 +33,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. @@ -109,7 +109,7 @@ export function activate(context: vscode.ExtensionContext) { registerCommitMessageService(context, connectionService) // Register code actions (editor context menus, terminal context menus, keyboard shortcuts) - registerCodeActions(context, provider) + registerCodeActions(context, provider, agentManagerProvider) registerTerminalActions(context, provider) // Register CodeActionProvider (lightbulb quick fixes) 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/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/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/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/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/HoleFiller.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts index 1e67e83e96..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 @@ -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 { @@ -200,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) 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/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/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/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/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/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/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index 6871465b13..7f6b842621 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -11,7 +11,9 @@ import type { McpStatus, McpConfig, Config, + KilocodeNotification, } from "./types" +import { extractHttpErrorMessage, parseSSEDataLine } from "./http-utils" /** * HTTP Client for communicating with the CLI backend server. @@ -67,15 +69,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, @@ -322,6 +316,19 @@ 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 (err) { + console.warn("[Kilo] Failed to fetch notifications:", err) + return [] + } + } + /** * Switch the active organization. * Pass null to switch back to personal account. @@ -398,38 +405,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..30a25fbbee --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/http-utils.ts @@ -0,0 +1,59 @@ +/** + * 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 (err) { + console.warn("[Kilo New] Failed to parse SSE data line", { err, line }) + return null + } +} 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/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/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/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/src/services/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts index 3cd63973a3..ef5df137de 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -1,9 +1,16 @@ import * as vscode from "vscode" import type { KiloProvider } from "../../KiloProvider" +import type { AgentManagerProvider } from "../../agent-manager/AgentManagerProvider" import { getEditorContext } from "./editor-utils" import { createPrompt } from "./support-prompt" -export function registerCodeActions(context: vscode.ExtensionContext, provider: KiloProvider): void { +export function registerCodeActions( + context: vscode.ExtensionContext, + provider: KiloProvider, + agentManager?: AgentManagerProvider, +): void { + const target = () => (agentManager?.isActive() ? agentManager : provider) + context.subscriptions.push( vscode.commands.registerCommand("kilo-code.new.explainCode", () => { const ctx = getEditorContext() @@ -54,12 +61,12 @@ export function registerCodeActions(context: vscode.ExtensionContext, provider: endLine: String(ctx.endLine), selectedText: ctx.selectedText, }) - provider.postMessage({ type: "setChatBoxMessage", text: prompt }) - provider.postMessage({ type: "action", action: "focusInput" }) + target().postMessage({ type: "setChatBoxMessage", text: prompt }) + target().postMessage({ type: "action", action: "focusInput" }) }), vscode.commands.registerCommand("kilo-code.new.focusChatInput", () => { - provider.postMessage({ type: "action", action: "focusInput" }) + target().postMessage({ type: "action", action: "focusInput" }) }), ) } 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/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/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 4bccd44da8..968c25d6e2 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -14,9 +14,17 @@ import { Project, SyntaxKind } from "ts-morph" const ROOT = path.resolve(import.meta.dir, "../..") const CSS_FILE = path.join(ROOT, "webview-ui/agent-manager/agent-manager.css") -const TSX_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx") +const TSX_FILES = [ + path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), + path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), +] +const TSX_FILE = TSX_FILES[0] const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") +function readAllTsx(): string { + return TSX_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n") +} + describe("Agent Manager CSS Prefix", () => { it("all class selectors should use am- prefix", () => { const css = fs.readFileSync(CSS_FILE, "utf-8") @@ -54,7 +62,7 @@ describe("Agent Manager CSS Prefix", () => { describe("Agent Manager CSS/TSX Consistency", () => { it("all classes used in TSX should be defined in CSS", () => { const css = fs.readFileSync(CSS_FILE, "utf-8") - const tsx = fs.readFileSync(TSX_FILE, "utf-8") + const tsx = readAllTsx() // Extract am- classes defined in CSS const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)] @@ -71,7 +79,7 @@ describe("Agent Manager CSS/TSX Consistency", () => { it("all am- classes defined in CSS should be used in TSX", () => { const css = fs.readFileSync(CSS_FILE, "utf-8") - const tsx = fs.readFileSync(TSX_FILE, "utf-8") + const tsx = readAllTsx() // Extract am- classes defined in CSS const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)] @@ -108,3 +116,144 @@ describe("Agent Manager Provider Messages", () => { expect(body).toContain("agentManager.sessionAdded") }) }) + +// --------------------------------------------------------------------------- +// Provider message routing — static-analysis regression tests +// +// These tests use ts-morph to inspect the source code of AgentManagerProvider +// and verify structural invariants that prevent regressions without needing +// a VS Code test host. +// --------------------------------------------------------------------------- + +describe("Agent Manager Provider — onMessage routing", () => { + let source: import("ts-morph").SourceFile + let cls: import("ts-morph").ClassDeclaration + + function setup() { + if (source) return + const project = new Project({ compilerOptions: { allowJs: true } }) + source = project.addSourceFileAtPath(PROVIDER_FILE) + cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)! + } + + function body(name: string): string { + setup() + const method = cls.getMethod(name) + expect(method, `method ${name} not found`).toBeTruthy() + return method!.getText() + } + + // -- onMessage dispatches all expected message types ----------------------- + + it("onMessage handles all documented agentManager.* message types", () => { + const text = body("onMessage") + const expected = [ + "agentManager.createWorktree", + "agentManager.deleteWorktree", + "agentManager.promoteSession", + "agentManager.addSessionToWorktree", + "agentManager.closeSession", + "agentManager.configureSetupScript", + "agentManager.showTerminal", + "agentManager.requestRepoInfo", + "agentManager.requestState", + "agentManager.setTabOrder", + ] + for (const msg of expected) { + expect(text, `onMessage should handle "${msg}"`).toContain(msg) + } + }) + + it("onMessage handles loadMessages for terminal switching", () => { + const text = body("onMessage") + expect(text).toContain("loadMessages") + expect(text).toContain("showExisting") + }) + + it("onMessage handles clearSession for SSE re-registration", () => { + const text = body("onMessage") + expect(text).toContain("clearSession") + expect(text).toContain("trackSession") + }) + + // -- onDeleteWorktree invariants ------------------------------------------- + + /** + * Regression: deletion must clean up both disk (manager) and state, then + * push to webview. Missing any step leaves ghost worktrees or stale UI. + */ + it("onDeleteWorktree removes from disk, state, clears orphans, and pushes", () => { + const text = body("onDeleteWorktree") + expect(text).toContain("manager.removeWorktree") + expect(text).toContain("state.removeWorktree") + expect(text).toContain("clearSessionDirectory") + expect(text).toContain("this.pushState()") + }) + + // -- onCreateWorktree invariants ------------------------------------------- + + /** + * Regression: the setup script MUST run before session creation. + * If reversed, the agent starts in an unconfigured worktree (missing .env, + * deps, etc.) which causes hard-to-debug failures. + */ + it("onCreateWorktree runs setup script before creating session", () => { + const text = body("onCreateWorktree") + const setupIdx = text.indexOf("runSetupScriptForWorktree") + const sessionIdx = text.indexOf("createSessionInWorktree") + expect(setupIdx, "setup script call must exist").toBeGreaterThan(-1) + expect(sessionIdx, "session creation call must exist").toBeGreaterThan(-1) + expect(setupIdx, "setup script must run before session creation").toBeLessThan(sessionIdx) + }) + + /** + * Regression: if session creation fails after the worktree was already + * created on disk, the worktree must be cleaned up to avoid orphaned dirs. + */ + it("onCreateWorktree cleans up worktree on session creation failure", () => { + const text = body("onCreateWorktree") + expect(text).toContain("removeWorktree") + }) + + // -- onPromoteSession invariants ------------------------------------------- + + /** + * Regression: same setup-before-move ordering as onCreateWorktree. + */ + it("onPromoteSession runs setup script before modifying session", () => { + const text = body("onPromoteSession") + const setupIdx = text.indexOf("runSetupScriptForWorktree") + const moveIdx = text.indexOf("moveSession") + expect(setupIdx).toBeGreaterThan(-1) + expect(moveIdx).toBeGreaterThan(-1) + expect(setupIdx, "setup must run before move").toBeLessThan(moveIdx) + }) + + /** + * Regression: promote must handle the case where the session doesn't + * exist in state yet (e.g. a workspace session that was never tracked). + * It must branch between addSession (new) and moveSession (existing). + */ + it("onPromoteSession handles both new and existing sessions", () => { + const text = body("onPromoteSession") + expect(text).toContain("getSession") + expect(text).toContain("addSession") + expect(text).toContain("moveSession") + }) + + // -- notifyWorktreeReady invariants ---------------------------------------- + + /** + * Regression: pushState must come before the ready/meta messages. + * If reversed, the webview receives the "ready" signal but can't find + * the worktree/session in state, causing a blank panel. + */ + it("notifyWorktreeReady pushes state before sending ready message", () => { + const text = body("notifyWorktreeReady") + const pushIdx = text.indexOf("this.pushState()") + const readyIdx = text.indexOf("agentManager.worktreeSetup") + expect(pushIdx, "pushState must come before worktreeSetup").toBeLessThan(readyIdx) + // Must also send sessionMeta so the webview knows the branch/path + expect(text).toContain("agentManager.sessionMeta") + }) +}) 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) + }) +}) 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..9060db822f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts @@ -0,0 +1,55 @@ +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("contains at least two colon-separated time components", () => { + const result = formatTime(Date.now()) + expect(result).toMatch(/\d+:\d+/) + }) + + 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(result).toMatch(/30/) + expect(result).toMatch(/45/) + }) + + 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)) + }) +}) 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) + }) +}) 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..415362ab7e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts @@ -0,0 +1,100 @@ +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") + }) +}) 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") + }) + }) + }) +}) 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() + }) +}) 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) + }) +}) 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") + }) +}) 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/tests/unit/format-keybinding.test.ts b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts new file mode 100644 index 0000000000..564b8a9ef3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "bun:test" +import { formatKeybinding } from "../../src/agent-manager/format-keybinding" + +describe("formatKeybinding", () => { + describe("mac", () => { + it("formats cmd as ⌘", () => { + expect(formatKeybinding("cmd+w", true)).toBe("⌘W") + }) + + it("formats cmd+shift as ⌘⇧", () => { + expect(formatKeybinding("cmd+shift+w", true)).toBe("⌘⇧W") + }) + + it("formats ctrl as ⌃", () => { + expect(formatKeybinding("ctrl+c", true)).toBe("⌃C") + }) + + it("formats alt as ⌥", () => { + expect(formatKeybinding("alt+f", true)).toBe("⌥F") + }) + + it("formats arrow keys as symbols", () => { + expect(formatKeybinding("cmd+left", true)).toBe("⌘←") + expect(formatKeybinding("cmd+right", true)).toBe("⌘→") + expect(formatKeybinding("cmd+up", true)).toBe("⌘↑") + expect(formatKeybinding("cmd+down", true)).toBe("⌘↓") + }) + + it("formats special keys", () => { + expect(formatKeybinding("cmd+backspace", true)).toBe("⌘⌫") + expect(formatKeybinding("cmd+enter", true)).toBe("⌘↵") + expect(formatKeybinding("escape", true)).toBe("Esc") + }) + + it("joins without separator on mac", () => { + expect(formatKeybinding("cmd+shift+alt+t", true)).toBe("⌘⇧⌥T") + }) + + it("formats plain key", () => { + expect(formatKeybinding("cmd+/", true)).toBe("⌘/") + }) + }) + + describe("windows/linux", () => { + it("formats cmd as Ctrl", () => { + expect(formatKeybinding("cmd+w", false)).toBe("Ctrl+W") + }) + + it("formats ctrl as Ctrl", () => { + expect(formatKeybinding("ctrl+w", false)).toBe("Ctrl+W") + }) + + it("formats ctrl+shift", () => { + expect(formatKeybinding("ctrl+shift+w", false)).toBe("Ctrl+Shift+W") + }) + + it("formats alt as Alt", () => { + expect(formatKeybinding("alt+f", false)).toBe("Alt+F") + }) + + it("formats arrow keys as symbols", () => { + expect(formatKeybinding("ctrl+left", false)).toBe("Ctrl+←") + expect(formatKeybinding("ctrl+right", false)).toBe("Ctrl+→") + expect(formatKeybinding("ctrl+up", false)).toBe("Ctrl+↑") + expect(formatKeybinding("ctrl+down", false)).toBe("Ctrl+↓") + }) + + it("joins with + separator on non-mac", () => { + expect(formatKeybinding("ctrl+shift+alt+t", false)).toBe("Ctrl+Shift+Alt+T") + }) + }) +}) 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") + }) +}) 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() + }) +}) 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("{{") + }) +}) 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/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() + }) +}) 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/tests/unit/message-contract.test.ts b/packages/kilo-vscode/tests/unit/message-contract.test.ts new file mode 100644 index 0000000000..c26c830c85 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/message-contract.test.ts @@ -0,0 +1,105 @@ +/** + * Message contract consistency tests. + * + * These tests verify that: + * 1. Every message type named in ExtensionMessage has a corresponding interface/type definition + * 2. Every WebviewMessage type handled in KiloProvider has a corresponding member in the WebviewMessage union + * 3. The message types used by mapSSEEventToWebviewMessage exist in ExtensionMessage + * + * These are static analysis tests - they read source files and check consistency. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const MESSAGES_FILE = path.join(ROOT, "webview-ui/src/types/messages.ts") +const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts") + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, "utf-8") +} + +describe("ExtensionMessage type members", () => { + it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => { + const content = readFile(MESSAGES_FILE) + + // Extract ExtensionMessage union members + const unionMatch = content.match( + /export type ExtensionMessage\s*=\s*([\s\S]*?)(?=\nexport type|\nexport interface|\nexport function|\n\/\/|$)/, + ) + if (!unionMatch) { + expect(false, "Could not find ExtensionMessage union in messages.ts").toBe(true) + return + } + + const unionBody = unionMatch[1]! + const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) + + const missing = memberNames.filter((name) => { + return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content) + }) + + expect(missing, `ExtensionMessage members without definitions: ${missing.join(", ")}`).toEqual([]) + }) + + it("all members of WebviewMessage union are defined as interfaces/types in messages.ts", () => { + const content = readFile(MESSAGES_FILE) + + const unionMatch = content.match(/export type WebviewMessage\s*=\s*([\s\S]*?)(?=\n\/\/|$)/) + if (!unionMatch) { + expect(false, "Could not find WebviewMessage union in messages.ts").toBe(true) + return + } + + const unionBody = unionMatch[1]! + const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) + + const missing = memberNames.filter((name) => { + return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content) + }) + + expect(missing, `WebviewMessage members without definitions: ${missing.join(", ")}`).toEqual([]) + }) +}) + +describe("KiloProvider message handler coverage", () => { + it("all WebviewMessage switch cases in KiloProvider exist in WebviewMessage union", () => { + const providerContent = readFile(KILO_PROVIDER_FILE) + const messagesContent = readFile(MESSAGES_FILE) + + // Extract case labels from handleWebviewMessage switch + const caseMatches = [...providerContent.matchAll(/case "([a-zA-Z]+)":/g)].map((m) => m[1]!) + + // Get all type values from WebviewMessage members + const typeValues = [...messagesContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) + const typeSet = new Set(typeValues) + + const unrecognized = caseMatches.filter((c) => !typeSet.has(c)) + + expect( + unrecognized, + `KiloProvider switch cases not found in any message type definition: ${unrecognized.join(", ")}`, + ).toEqual([]) + }) +}) + +describe("mapSSEEventToWebviewMessage output types", () => { + it("all output types from mapSSEEventToWebviewMessage exist in ExtensionMessage", () => { + const utilsContent = readFile(KILO_PROVIDER_UTILS_FILE) + const messagesContent = readFile(MESSAGES_FILE) + + // Extract type literals used in the return values of mapSSEEventToWebviewMessage + const typeMatches = [...utilsContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) + + // Get all type values defined in messages.ts + const allTypes = [...messagesContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) + const typeSet = new Set(allTypes) + + const missing = typeMatches.filter((t) => !typeSet.has(t)) + + expect(missing, `Types in mapSSEEventToWebviewMessage not in messages.ts: ${missing.join(", ")}`).toEqual([]) + }) +}) 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/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index d46314a387..219fb495b5 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { resolveNavigation, validateLocalSession, LOCAL } from "../../webview-ui/agent-manager/navigate" +import { resolveNavigation, validateLocalSession, adjacentHint, LOCAL } from "../../webview-ui/agent-manager/navigate" const ids = ["a", "b", "c", "d"] @@ -134,3 +134,49 @@ describe("validateLocalSession", () => { expect(validateLocalSession(undefined, [])).toBeUndefined() }) }) + +describe("adjacentHint", () => { + const flat = [LOCAL, "wt1", "wt2", "wt3", "s1"] + + it("returns prev hint when item is directly above active", () => { + expect(adjacentHint("wt1", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↑") + }) + + it("returns next hint when item is directly below active", () => { + expect(adjacentHint("wt3", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↓") + }) + + it("returns empty string for the active item itself", () => { + expect(adjacentHint("wt2", "wt2", flat, "⌘↑", "⌘↓")).toBe("") + }) + + it("returns empty string for non-adjacent items", () => { + expect(adjacentHint("wt1", "wt3", flat, "⌘↑", "⌘↓")).toBe("") + expect(adjacentHint("s1", "wt1", flat, "⌘↑", "⌘↓")).toBe("") + }) + + it("returns empty string when active is undefined", () => { + expect(adjacentHint("wt1", undefined, flat, "⌘↑", "⌘↓")).toBe("") + }) + + it("returns empty string when active is not in list", () => { + expect(adjacentHint("wt1", "unknown", flat, "⌘↑", "⌘↓")).toBe("") + }) + + it("returns empty string when item is not in list", () => { + expect(adjacentHint("unknown", "wt2", flat, "⌘↑", "⌘↓")).toBe("") + }) + + it("works at boundaries — first item with LOCAL active", () => { + expect(adjacentHint("wt1", LOCAL, flat, "⌘↑", "⌘↓")).toBe("⌘↓") + }) + + it("works at boundaries — LOCAL with first item active", () => { + expect(adjacentHint(LOCAL, "wt1", flat, "⌘↑", "⌘↓")).toBe("⌘↑") + }) + + it("works with single-item list", () => { + expect(adjacentHint("a", "b", ["a", "b"], "prev", "next")).toBe("prev") + expect(adjacentHint("b", "a", ["a", "b"], "prev", "next")).toBe("next") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/permission-queue.test.ts b/packages/kilo-vscode/tests/unit/permission-queue.test.ts index e1fc360586..11a0684ed3 100644 --- a/packages/kilo-vscode/tests/unit/permission-queue.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-queue.test.ts @@ -1,53 +1,90 @@ -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) }) }) 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/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/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() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts new file mode 100644 index 0000000000..8d84dd7bcd --- /dev/null +++ b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "bun:test" +import { toggleAnswer, buildSubtitleText } from "../../webview-ui/src/components/chat/question-dock-utils" + +describe("toggleAnswer", () => { + it("adds answer when not present", () => { + expect(toggleAnswer([], "option-a")).toEqual(["option-a"]) + }) + + it("removes answer when already present", () => { + expect(toggleAnswer(["option-a"], "option-a")).toEqual([]) + }) + + it("adds to existing answers without removing others", () => { + const result = toggleAnswer(["a", "b"], "c") + expect(result).toEqual(["a", "b", "c"]) + }) + + it("removes from the middle without affecting other entries", () => { + const result = toggleAnswer(["a", "b", "c"], "b") + expect(result).toEqual(["a", "c"]) + }) + + it("does not mutate the original array", () => { + const original = ["a", "b"] + toggleAnswer(original, "c") + expect(original).toEqual(["a", "b"]) + }) + + it("handles empty answer string", () => { + expect(toggleAnswer([], "")).toEqual([""]) + expect(toggleAnswer([""], "")).toEqual([]) + }) + + it("only removes the first occurrence (deduplication edge case)", () => { + const result = toggleAnswer(["a", "a"], "a") + expect(result).toEqual(["a"]) + }) +}) + +describe("buildSubtitleText", () => { + it("returns empty string for count of 0", () => { + expect(buildSubtitleText(0, "question", "questions")).toBe("") + }) + + it("uses singular form for count of 1", () => { + expect(buildSubtitleText(1, "question", "questions")).toBe("1 question") + }) + + it("uses plural form for count of 2", () => { + expect(buildSubtitleText(2, "question", "questions")).toBe("2 questions") + }) + + it("uses plural form for large counts", () => { + expect(buildSubtitleText(10, "question", "questions")).toBe("10 questions") + }) + + it("works with i18n-style translation strings", () => { + expect(buildSubtitleText(1, "ui.common.question.one", "ui.common.question.other")).toBe("1 ui.common.question.one") + expect(buildSubtitleText(3, "ui.common.question.one", "ui.common.question.other")).toBe( + "3 ui.common.question.other", + ) + }) +}) 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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts new file mode 100644 index 0000000000..d3ce08e789 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -0,0 +1,78 @@ +/** + * SessionTerminalManager tests. + * + * The class is tightly coupled to VS Code terminal APIs. We use ts-morph + * static analysis to verify structural invariants that protect against + * real regressions — focusing on ordering constraints and cleanup logic + * that are easy to break during refactoring. + */ + +import { describe, it, expect } from "bun:test" +import path from "node:path" +import { Project, SyntaxKind } from "ts-morph" + +const ROOT = path.resolve(import.meta.dir, "../..") +const FILE = path.join(ROOT, "src/agent-manager/SessionTerminalManager.ts") + +function getClass() { + const project = new Project({ compilerOptions: { allowJs: true } }) + const source = project.addSourceFileAtPath(FILE) + return source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)! +} + +function body(name: string): string { + const cls = getClass() + const method = cls.getMethod(name) + expect(method, `method ${name} not found in SessionTerminalManager`).toBeTruthy() + return method!.getText() +} + +describe("SessionTerminalManager structure", () => { + it("constructor registers both terminal lifecycle listeners", () => { + const cls = getClass() + const ctor = cls.getConstructors()[0] + expect(ctor).toBeTruthy() + const text = ctor!.getText() + // Both listeners are required: close (cleanup) and active-change (context key) + expect(text).toContain("onDidCloseTerminal") + expect(text).toContain("onDidChangeActiveTerminal") + }) + + it("dispose clears the context key, disposes terminals, and clears the map", () => { + const text = body("dispose") + // All three are required for clean shutdown — missing any would leak resources + expect(text).toContain("kilo-code.agentTerminalFocus") + expect(text).toContain("terminal.dispose()") + expect(text).toContain("terminals.clear()") + }) + + it("showTerminal resolves CWD from worktree with workspace fallback", () => { + const text = body("showTerminal") + // The fallback chain must be worktreePath ?? workspacePath, not the reverse. + // Getting this wrong would run agents in the wrong directory. + expect(text).toContain("worktreePath ?? workspacePath") + }) + + /** + * Regression: showOrCreate must check exitStatus before checking CWD changes. + * If reversed, a stale exited terminal with a different CWD would hit the + * dispose path instead of the cleanup path, potentially leaving ghost entries. + */ + it("showOrCreate checks exit status before CWD mismatch", () => { + const text = body("showOrCreate") + const exitIdx = text.indexOf("exitStatus") + const cwdIdx = text.indexOf("entry.cwd !== cwd") + expect(exitIdx).toBeGreaterThan(-1) + expect(cwdIdx).toBeGreaterThan(-1) + expect(exitIdx, "exit check must come before cwd check").toBeLessThan(cwdIdx) + }) + + it("showOrCreate updates context key after showing terminal", () => { + const text = body("showOrCreate") + const showIdx = text.lastIndexOf("entry.terminal.show") + const contextIdx = text.lastIndexOf("this.updateContextKey()") + expect(showIdx).toBeGreaterThan(-1) + expect(contextIdx).toBeGreaterThan(-1) + expect(showIdx, "show must precede updateContextKey").toBeLessThan(contextIdx) + }) +}) 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/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") + }) +}) 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]+\}/) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/tab-order.test.ts b/packages/kilo-vscode/tests/unit/tab-order.test.ts new file mode 100644 index 0000000000..999bb9b267 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/tab-order.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "bun:test" +import { reorderTabs, applyTabOrder, firstOrderedTitle } from "../../webview-ui/agent-manager/tab-order" + +describe("reorderTabs", () => { + const tabs = ["a", "b", "c", "d"] + + it("moves an item forward", () => { + expect(reorderTabs(tabs, "a", "c")).toEqual(["b", "c", "a", "d"]) + }) + + it("moves an item backward", () => { + expect(reorderTabs(tabs, "c", "a")).toEqual(["c", "a", "b", "d"]) + }) + + it("swaps adjacent items forward", () => { + expect(reorderTabs(tabs, "a", "b")).toEqual(["b", "a", "c", "d"]) + }) + + it("swaps adjacent items backward", () => { + expect(reorderTabs(tabs, "b", "a")).toEqual(["b", "a", "c", "d"]) + }) + + it("moves first to last", () => { + expect(reorderTabs(tabs, "a", "d")).toEqual(["b", "c", "d", "a"]) + }) + + it("moves last to first", () => { + expect(reorderTabs(tabs, "d", "a")).toEqual(["d", "a", "b", "c"]) + }) + + it("returns undefined when from equals to", () => { + expect(reorderTabs(tabs, "a", "a")).toBeUndefined() + }) + + it("returns undefined when from is not found", () => { + expect(reorderTabs(tabs, "x", "a")).toBeUndefined() + }) + + it("returns undefined when to is not found", () => { + expect(reorderTabs(tabs, "a", "x")).toBeUndefined() + }) + + it("returns undefined when both are missing", () => { + expect(reorderTabs(tabs, "x", "y")).toBeUndefined() + }) + + it("handles a two-item list", () => { + expect(reorderTabs(["a", "b"], "a", "b")).toEqual(["b", "a"]) + expect(reorderTabs(["a", "b"], "b", "a")).toEqual(["b", "a"]) + }) + + it("handles a single-item list (from === to)", () => { + expect(reorderTabs(["a"], "a", "a")).toBeUndefined() + }) + + it("handles empty list", () => { + expect(reorderTabs([], "a", "b")).toBeUndefined() + }) + + it("does not mutate the original array", () => { + const original = ["a", "b", "c"] + reorderTabs(original, "a", "c") + expect(original).toEqual(["a", "b", "c"]) + }) + + it("preserves unrelated items", () => { + const result = reorderTabs(["a", "b", "c", "d", "e"], "b", "d")! + expect(result).toEqual(["a", "c", "d", "b", "e"]) + expect(result.sort()).toEqual(["a", "b", "c", "d", "e"]) + }) + + it("round-trip: moving forward then back restores original order", () => { + const moved = reorderTabs(tabs, "a", "c")! + const restored = reorderTabs(moved, "a", "b")! + expect(restored).toEqual(["a", "b", "c", "d"]) + }) +}) + +describe("applyTabOrder", () => { + const items = [ + { id: "a", name: "Alice" }, + { id: "b", name: "Bob" }, + { id: "c", name: "Carol" }, + ] + + it("reorders items according to custom order", () => { + const result = applyTabOrder(items, ["c", "a", "b"]) + expect(result.map((i) => i.id)).toEqual(["c", "a", "b"]) + }) + + it("appends items not in the order", () => { + const result = applyTabOrder(items, ["b"]) + expect(result.map((i) => i.id)).toEqual(["b", "a", "c"]) + }) + + it("skips order IDs that are not in items", () => { + const result = applyTabOrder(items, ["x", "c", "y", "a"]) + expect(result.map((i) => i.id)).toEqual(["c", "a", "b"]) + }) + + it("returns original array when order is undefined", () => { + const result = applyTabOrder(items, undefined) + expect(result).toBe(items) + }) + + it("returns original array when order is empty", () => { + const result = applyTabOrder(items, []) + expect(result).toBe(items) + }) + + it("handles empty items", () => { + expect(applyTabOrder([], ["a", "b"])).toEqual([]) + }) + + it("preserves item properties", () => { + const result = applyTabOrder(items, ["b", "a", "c"]) + expect(result[0]).toEqual({ id: "b", name: "Bob" }) + }) +}) + +describe("firstOrderedTitle", () => { + const items = [{ id: "a", title: "Alpha" }, { id: "b", title: "Beta" }, { id: "c", title: "" }, { id: "d" }] + + it("returns first titled item from custom order", () => { + expect(firstOrderedTitle(items, ["b", "a"], "fallback")).toBe("Beta") + }) + + it("skips items without titles in order", () => { + expect(firstOrderedTitle(items, ["d", "c", "b"], "fallback")).toBe("Beta") + }) + + it("falls back to first titled item when order has no matches", () => { + expect(firstOrderedTitle(items, ["x", "y"], "fallback")).toBe("Alpha") + }) + + it("falls back to first titled item when order is undefined", () => { + expect(firstOrderedTitle(items, undefined, "fallback")).toBe("Alpha") + }) + + it("returns fallback when no items have titles", () => { + expect(firstOrderedTitle([{ id: "a" }, { id: "b", title: "" }], ["a", "b"], "fallback")).toBe("fallback") + }) + + it("returns fallback for empty items", () => { + expect(firstOrderedTitle([], ["a"], "fallback")).toBe("fallback") + }) +}) + +// Helper: simulate reconciliation the same way handleDragOver does +function reconcile(current: string[], stored: string[]): string[] { + return applyTabOrder( + current.map((id) => ({ id })), + stored, + ).map((item) => item.id) +} + +describe("applyTabOrder as reconciliation (string IDs)", () => { + it("returns stored order unchanged when it matches current IDs", () => { + expect(reconcile(["a", "b", "c"], ["a", "b", "c"])).toEqual(["a", "b", "c"]) + }) + + it("appends new IDs not in stored order", () => { + expect(reconcile(["a", "b", "c"], ["a", "b"])).toEqual(["a", "b", "c"]) + }) + + it("removes stale IDs no longer in current", () => { + expect(reconcile(["a", "c"], ["a", "b", "c"])).toEqual(["a", "c"]) + }) + + it("preserves custom ordering while adding new tabs", () => { + expect(reconcile(["a", "b", "c"], ["b", "a"])).toEqual(["b", "a", "c"]) + }) + + it("returns current IDs when stored order is undefined", () => { + expect(applyTabOrder([{ id: "a" }, { id: "b" }], undefined).map((i) => i.id)).toEqual(["a", "b"]) + }) + + describe("regression: reorder a newly added tab immediately", () => { + it("new tab should be reorderable after reconcile via applyTabOrder", () => { + // Stored order from a previous drag: [s2, s1] + // A third session s3 was just added to the worktree + const stored = ["s2", "s1"] + const current = ["s2", "s1", "s3"] + + const reconciled = reconcile(current, stored) + expect(reconciled).toEqual(["s2", "s1", "s3"]) + + // Now the user drags s3 to position of s2 — this must succeed + const reordered = reorderTabs(reconciled, "s3", "s2") + expect(reordered).toEqual(["s3", "s2", "s1"]) + expect(reordered).not.toBeUndefined() + }) + + it("without reconcile, reorderTabs fails on the new tab", () => { + const stored = ["s2", "s1"] + const reordered = reorderTabs(stored, "s3", "s2") + expect(reordered).toBeUndefined() + }) + }) +}) 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) + } + }) +}) 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..a82e64df87 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts @@ -0,0 +1,54 @@ +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}`) + }) +}) 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..ae6011007d --- /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(result).toBe(" return x + y;") + }) + + it("returns undefined for empty suggestion", () => { + const result = postprocessAutocompleteSuggestion({ + suggestion: "", + prefix: "const x = ", + suffix: "", + model: "gpt-4", + }) + expect(result).toBeUndefined() + }) +}) 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() + }) +}) 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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 131fb0043c..6a15a2cf12 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -372,3 +372,81 @@ describe("WorktreeManager.ensureGitExclude", () => { expect(count).toBe(1) }) }) + +// --------------------------------------------------------------------------- +// WorktreeManager -- branch name collision retry +// --------------------------------------------------------------------------- + +describe("WorktreeManager.createWorktree branch collision", () => { + /** + * Exercise the retry path at WorktreeManager.ts:77-86. + * + * The collision happens when `git worktree add -b ` fails because + * a branch with that name already exists. generateBranchName appends + * Date.now() making it hard to predict. We force the collision by + * monkey-patching Date.now to return a fixed value for the duration of + * the branch name generation, guaranteeing the same name is produced + * twice. + */ + it("retries with a unique suffix when generated branch name collides", async () => { + const root = await createTempRepo() + const git = simpleGit(root) + const mgr = createManager(root) + + // Create a first worktree — this consumes a branch name + const first = await mgr.createWorktree({ prompt: "collide" }) + const firstBranch = first.branch + + // Remove the worktree via git but keep the branch ref alive + await git.raw(["worktree", "remove", "--force", first.path]) + + // Verify the branch still exists (worktree is gone, branch is not) + const branches = await git.branch() + expect(branches.all).toContain(firstBranch) + + // Force Date.now to return the same timestamp that produced firstBranch. + // firstBranch is "collide-", extract that timestamp. + const timestamp = firstBranch.replace("collide-", "") + const real = Date.now + Date.now = () => Number(timestamp) + try { + // This will generate the same branch name and hit the collision retry + const second = await mgr.createWorktree({ prompt: "collide" }) + + // The retry appends a new timestamp suffix, so the branch name differs + expect(second.branch).not.toBe(firstBranch) + expect(second.branch).toStartWith("collide-") + + const stat = await fs.stat(path.join(second.path, ".git")) + expect(stat.isFile()).toBe(true) + } finally { + Date.now = real + } + }) +}) + +// --------------------------------------------------------------------------- +// WorktreeManager -- removeWorktree safety guard +// --------------------------------------------------------------------------- + +describe("WorktreeManager.removeWorktree safety", () => { + it("refuses to remove paths outside the worktrees directory", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + + // Create a directory outside .kilocode/worktrees/ + const outside = path.join(root, "important-data") + await fs.mkdir(outside, { recursive: true }) + await fs.writeFile(path.join(outside, "file.txt"), "precious") + + // Attempt to remove it — should be silently refused + await mgr.removeWorktree(outside) + + // Directory should still exist + const exists = await fs + .stat(outside) + .then(() => true) + .catch(() => false) + expect(exists).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index decdcc9e8c..d905f9d6e4 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -174,6 +174,115 @@ describe("WorktreeStateManager", () => { }) }) + describe("tab order", () => { + it("sets and gets tab order for a key", () => { + manager.setTabOrder("wt-1", ["s1", "s2", "s3"]) + expect(manager.getTabOrder()["wt-1"]).toEqual(["s1", "s2", "s3"]) + }) + + it("overwrites existing tab order", () => { + manager.setTabOrder("wt-1", ["s1", "s2"]) + manager.setTabOrder("wt-1", ["s2", "s1"]) + expect(manager.getTabOrder()["wt-1"]).toEqual(["s2", "s1"]) + }) + + it("removes tab order for a key", () => { + manager.setTabOrder("wt-1", ["s1"]) + manager.removeTabOrder("wt-1") + expect(manager.getTabOrder()["wt-1"]).toBeUndefined() + }) + + it("removeTabOrder is a no-op for missing key", () => { + manager.removeTabOrder("nonexistent") + expect(Object.keys(manager.getTabOrder())).toHaveLength(0) + }) + + it("cleans up tab order when worktree is removed", () => { + const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" }) + manager.addSession("s1", wt.id) + manager.setTabOrder(wt.id, ["s1"]) + + manager.removeWorktree(wt.id) + expect(manager.getTabOrder()[wt.id]).toBeUndefined() + }) + + it("removes session from tab order arrays when session is removed", () => { + const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" }) + manager.addSession("s1", wt.id) + manager.addSession("s2", wt.id) + manager.setTabOrder(wt.id, ["s1", "s2"]) + + manager.removeSession("s1") + expect(manager.getTabOrder()[wt.id]).toEqual(["s2"]) + }) + + it("removes tab order entry when last session in order is removed", () => { + manager.addSession("s1", null) + manager.setTabOrder("local", ["s1"]) + + manager.removeSession("s1") + expect(manager.getTabOrder()["local"]).toBeUndefined() + }) + + it("persists and loads tab order", async () => { + const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" }) + manager.setTabOrder(wt.id, ["s2", "s1"]) + manager.setTabOrder("local", ["s3", "s4"]) + await manager.flush() + await manager.save() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + + expect(loaded.getTabOrder()[wt.id]).toEqual(["s2", "s1"]) + expect(loaded.getTabOrder()["local"]).toEqual(["s3", "s4"]) + }) + + it("does not persist empty tab order", async () => { + manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" }) + await manager.flush() + await manager.save() + + const content = fs.readFileSync(path.join(root, ".kilocode", "agent-manager.json"), "utf-8") + const data = JSON.parse(content) + expect(data.tabOrder).toBeUndefined() + }) + }) + + describe("sessionsCollapsed", () => { + it("defaults to false", () => { + expect(manager.getSessionsCollapsed()).toBe(false) + }) + + it("sets and gets collapsed state", () => { + manager.setSessionsCollapsed(true) + expect(manager.getSessionsCollapsed()).toBe(true) + + manager.setSessionsCollapsed(false) + expect(manager.getSessionsCollapsed()).toBe(false) + }) + + it("persists and loads collapsed state", async () => { + manager.setSessionsCollapsed(true) + await manager.flush() + await manager.save() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getSessionsCollapsed()).toBe(true) + }) + + it("does not persist when false", async () => { + manager.setSessionsCollapsed(false) + await manager.flush() + await manager.save() + + const content = fs.readFileSync(path.join(root, ".kilocode", "agent-manager.json"), "utf-8") + const data = JSON.parse(content) + expect(data.sessionsCollapsed).toBeUndefined() + }) + }) + describe("validate", () => { it("removes worktrees whose directories do not exist", async () => { const existing = path.join(root, "wt-exists") @@ -202,4 +311,125 @@ describe("WorktreeStateManager", () => { expect(manager.getWorktrees()).toHaveLength(1) }) }) + + describe("concurrent save serialization", () => { + it("rapid mutations do not lose data after flush", async () => { + // Fire many mutations without awaiting saves individually + for (let i = 0; i < 20; i++) { + manager.addWorktree({ branch: `b-${i}`, path: `/tmp/b-${i}`, parentBranch: "main" }) + } + for (let i = 0; i < 20; i++) { + manager.addSession(`s-${i}`, null) + } + + // Wait for all fire-and-forget saves to settle + await manager.flush() + await manager.save() + + // Reload from disk and verify all data persisted + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + + expect(loaded.getWorktrees()).toHaveLength(20) + expect(loaded.getSessions()).toHaveLength(20) + for (let i = 0; i < 20; i++) { + expect(loaded.getWorktrees().find((w) => w.branch === `b-${i}`)).toBeTruthy() + expect(loaded.getSession(`s-${i}`)).toBeTruthy() + } + }) + + it("interleaved add and remove persists correctly", async () => { + const wt1 = manager.addWorktree({ branch: "keep", path: "/tmp/keep", parentBranch: "main" }) + const wt2 = manager.addWorktree({ branch: "remove", path: "/tmp/remove", parentBranch: "main" }) + manager.addSession("s1", wt1.id) + manager.addSession("s2", wt2.id) + manager.removeWorktree(wt2.id) + manager.addSession("s3", wt1.id) + + await manager.flush() + await manager.save() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + + expect(loaded.getWorktrees()).toHaveLength(1) + expect(loaded.getWorktrees()[0].branch).toBe("keep") + // s2 was orphaned when wt2 was removed, s1 and s3 belong to wt1 + expect(loaded.getSession("s1")?.worktreeId).toBe(wt1.id) + expect(loaded.getSession("s2")?.worktreeId).toBeNull() + expect(loaded.getSession("s3")?.worktreeId).toBe(wt1.id) + }) + + it("concurrent save() calls resolve without data loss", async () => { + manager.addWorktree({ branch: "first", path: "/tmp/first", parentBranch: "main" }) + + // Trigger multiple saves concurrently — the second should queue behind the first + const p1 = manager.save() + manager.addWorktree({ branch: "second", path: "/tmp/second", parentBranch: "main" }) + const p2 = manager.save() + await Promise.all([p1, p2]) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktrees()).toHaveLength(2) + }) + + it("flush resolves after in-flight save completes", async () => { + manager.addWorktree({ branch: "flush-test", path: "/tmp/flush", parentBranch: "main" }) + // Don't await — let save fire in background + void manager.save() + // flush must wait for it + await manager.flush() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktrees().find((w) => w.branch === "flush-test")).toBeTruthy() + }) + }) + + describe("load with corrupt data", () => { + it("handles malformed JSON gracefully", async () => { + const file = path.join(root, ".kilocode", "agent-manager.json") + fs.writeFileSync(file, "not-valid-json{{{", "utf-8") + + await manager.load() + + // State should be empty — no crash + expect(manager.getWorktrees()).toHaveLength(0) + expect(manager.getSessions()).toHaveLength(0) + // Should have logged an error + expect(logs.some((l) => l.includes("Failed to load state"))).toBe(true) + }) + + it("handles partial data with missing sessions key", async () => { + const file = path.join(root, ".kilocode", "agent-manager.json") + fs.writeFileSync( + file, + JSON.stringify({ + worktrees: { "wt-1": { branch: "a", path: "/a", parentBranch: "main", createdAt: new Date().toISOString() } }, + }), + "utf-8", + ) + + await manager.load() + + expect(manager.getWorktrees()).toHaveLength(1) + expect(manager.getWorktrees()[0].branch).toBe("a") + expect(manager.getSessions()).toHaveLength(0) + }) + + it("handles partial data with missing worktrees key", async () => { + const file = path.join(root, ".kilocode", "agent-manager.json") + fs.writeFileSync( + file, + JSON.stringify({ sessions: { "s-1": { worktreeId: null, createdAt: new Date().toISOString() } } }), + "utf-8", + ) + + await manager.load() + + expect(manager.getWorktrees()).toHaveLength(0) + expect(manager.getSessions()).toHaveLength(1) + }) + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b2017be6af..d4c620ae30 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -16,24 +16,32 @@ import type { AgentManagerRepoInfoMessage, AgentManagerWorktreeSetupMessage, AgentManagerStateMessage, + AgentManagerKeybindingsMessage, + AgentManagerMultiVersionProgressMessage, + AgentManagerSendInitialMessage, WorktreeState, ManagedSessionState, SessionInfo, } from "../src/types/messages" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" +import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" 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" import { Spinner } from "@kilocode/kilo-ui/spinner" -import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { HoverCard } from "@kilocode/kilo-ui/hover-card" import { VSCodeProvider, useVSCode } from "../src/context/vscode" import { ServerProvider } from "../src/context/server" import { ProviderProvider } from "../src/context/provider" @@ -41,9 +49,13 @@ import { ConfigProvider } from "../src/context/config" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" +import { ModelSelectorBase } from "../src/components/chat/ModelSelector" +import { ModeSwitcherBase } from "../src/components/chat/ModeSwitcher" import { LanguageBridge, DataBridge } from "../src/App" import { formatRelativeDate } from "../src/utils/date" -import { validateLocalSession, nextSelectionAfterDelete, LOCAL } from "./navigate" +import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate" +import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order" +import { ConstrainDragYAxis, SortableTab } from "./sortable-tab" import "./agent-manager.css" interface SetupState { @@ -57,7 +69,21 @@ interface SetupState { type SidebarSelection = typeof LOCAL | string | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) -const modKey = isMac ? "\u2318" : "Ctrl+" + +// Fallback keybindings before extension sends resolved ones +const defaultBindings: Record = { + previousSession: isMac ? "⌘↑" : "Ctrl+↑", + nextSession: isMac ? "⌘↓" : "Ctrl+↓", + previousTab: isMac ? "⌘←" : "Ctrl+←", + nextTab: isMac ? "⌘→" : "Ctrl+→", + showTerminal: isMac ? "⌘/" : "Ctrl+/", + newTab: isMac ? "⌘T" : "Ctrl+T", + closeTab: isMac ? "⌘W" : "Ctrl+W", + newWorktree: isMac ? "⌘N" : "Ctrl+N", + closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W", + agentManagerOpen: isMac ? "⌘⇧M" : "Ctrl+Shift+M", + focusPanel: isMac ? "⌘." : "Ctrl+.", +} /** Manages horizontal scroll for the tab list: hides the scrollbar, converts * vertical wheel events to horizontal scroll, tracks overflow to show/hide @@ -124,21 +150,103 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor): ShortcutCategory[] { + return [ + { + title: "Sidebar", + shortcuts: [ + { label: "Previous item", binding: bindings.previousSession ?? "" }, + { label: "Next item", binding: bindings.nextSession ?? "" }, + { label: "New worktree", binding: bindings.newWorktree ?? "" }, + { label: "Delete worktree", binding: bindings.closeWorktree ?? "" }, + ], + }, + { + title: "Tabs", + shortcuts: [ + { label: "Previous tab", binding: bindings.previousTab ?? "" }, + { label: "Next tab", binding: bindings.nextTab ?? "" }, + { label: "New tab", binding: bindings.newTab ?? "" }, + { label: "Close tab", binding: bindings.closeTab ?? "" }, + ], + }, + { + title: "Terminal", + shortcuts: [ + { label: "Toggle terminal", binding: bindings.showTerminal ?? "" }, + { label: "Focus panel", binding: bindings.focusPanel ?? "" }, + ], + }, + { + title: "Global", + shortcuts: [{ label: "Open Agent Manager", binding: bindings.agentManagerOpen ?? "" }].filter((s) => s.binding), + }, + ].filter((c) => c.shortcuts.length > 0) +} + +/** Parse a display keybinding string into separate key tokens for rendering. + * Windows/Linux format ("Ctrl+Shift+W") splits on "+". + * Mac format ("⌘⇧W") splits on known modifier symbols. */ +function parseBindingTokens(binding: string): string[] { + if (!binding) return [] + // Windows/Linux: "Ctrl+Shift+W" → ["Ctrl", "Shift", "W"] + if (binding.includes("+")) return binding.split("+") + // Mac: "⌘⇧W" → ["⌘", "⇧", "W"] — peel off known modifier symbols + const tokens: string[] = [] + let rest = binding + const modifiers = ["⌘", "⇧", "⌃", "⌥"] + while (rest.length > 0) { + const mod = modifiers.find((m) => rest.startsWith(m)) + if (mod) { + tokens.push(mod) + rest = rest.slice(mod.length) + } else { + tokens.push(rest) + break + } + } + return tokens +} + const AgentManagerContent: Component = () => { const session = useSession() const vscode = useVSCode() const dialog = useDialog() + const [kb, setKb] = createSignal>(defaultBindings) + const [setup, setSetup] = createSignal({ active: false, message: "" }) const [worktrees, setWorktrees] = createSignal([]) const [managedSessions, setManagedSessions] = createSignal([]) const [selection, setSelection] = createSignal(LOCAL) const [repoBranch, setRepoBranch] = createSignal() const [deletingWorktrees, setDeletingWorktrees] = createSignal>(new Set()) + const [loadingWorktrees, setLoadingWorktrees] = createSignal>(new Set()) + const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) + const [sessionsLoaded, setSessionsLoaded] = createSignal(false) + const [isGitRepo, setIsGitRepo] = createSignal(true) + + 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) + const [sessionsCollapsed, setSessionsCollapsed] = createSignal(false) // Pending local tab counter for generating unique IDs let pendingCounter = 0 @@ -150,6 +258,11 @@ const AgentManagerContent: Component = () => { const isPending = (id: string) => id.startsWith(PENDING_PREFIX) + // Drag-and-drop state for tab reordering + const [draggingTab, setDraggingTab] = createSignal() + // Tab ordering: context key → ordered session ID array (recovered from extension state) + const [worktreeTabOrder, setWorktreeTabOrder] = createSignal>({}) + const addPendingTab = () => { const id = `${PENDING_PREFIX}${++pendingCounter}` setLocalSessionIDs((prev) => [...prev, id]) @@ -158,9 +271,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 @@ -221,16 +337,17 @@ const AgentManagerContent: Component = () => { return result }) - // Sessions for the currently selected worktree (tab bar), sorted by creation date + // Sessions for the currently selected worktree (tab bar), respecting custom order if set const activeWorktreeSessions = createMemo((): SessionInfo[] => { const sel = selection() if (!sel || sel === LOCAL) return [] const managed = managedSessions().filter((ms) => ms.worktreeId === sel) const ids = new Set(managed.map((ms) => ms.id)) - return session + const sessions = session .sessions() .filter((s) => ids.has(s.id)) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + return applyTabOrder(sessions, worktreeTabOrder()[sel]) }) // Active tab sessions: local sessions when on "local", worktree sessions otherwise @@ -256,12 +373,66 @@ const AgentManagerContent: Component = () => { const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId()) const tabScroll = useTabScroll(activeTabs, visibleTabId) - // Display name for worktree + // Display name for worktree — uses first tab in custom order when available const worktreeLabel = (wt: WorktreeState): string => { const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id) const ids = new Set(managed.map((ms) => ms.id)) - const first = session.sessions().find((s) => ids.has(s.id)) - return first?.title || wt.branch + const sessions = session.sessions().filter((s) => ids.has(s.id)) + return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch) + } + + /** Worktrees sorted so that grouped items are always adjacent, ordered by creation time. */ + const sortedWorktrees = createMemo(() => { + const all = worktrees() + if (all.length === 0) return [] + + // Collect grouped worktrees by groupId + const grouped = new Map() + for (const wt of all) { + if (!wt.groupId) continue + const list = grouped.get(wt.groupId) ?? [] + list.push(wt) + grouped.set(wt.groupId, list) + } + + // Build output: interleave groups at the position of their earliest member + const result: WorktreeState[] = [] + const placed = new Set() + for (const wt of all) { + if (placed.has(wt.id)) continue + if (wt.groupId) { + if (placed.has(wt.groupId)) continue + placed.add(wt.groupId) + const group = grouped.get(wt.groupId) ?? [] + for (const g of group) { + result.push(g) + placed.add(g.id) + } + } else { + result.push(wt) + placed.add(wt.id) + } + } + return result + }) + + /** Check if this worktree is part of a group. */ + const isGrouped = (wt: WorktreeState) => !!wt.groupId + + /** Check if this is the first item in its group. */ + const isGroupStart = (wt: WorktreeState, idx: number) => { + if (!wt.groupId) return false + const list = sortedWorktrees() + if (idx === 0) return true + return list[idx - 1]?.groupId !== wt.groupId + } + + /** Check if this is the last item in its group. */ + const isGroupEnd = (wt: WorktreeState, idx: number) => { + if (!wt.groupId) return false + const list = sortedWorktrees() + if (idx === list.length - 1) return true + return list[idx + 1]?.groupId !== wt.groupId } const scrollIntoView = (el: HTMLElement) => { @@ -367,6 +538,7 @@ const AgentManagerContent: Component = () => { else if (msg.action === "closeTab") closeActiveTab() else if (msg.action === "newWorktree") handleNewWorktreeOrPromote() else if (msg.action === "closeWorktree") closeSelectedWorktree() + else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt")) } window.addEventListener("message", handler) @@ -388,7 +560,14 @@ const AgentManagerContent: Component = () => { window.addEventListener("keydown", preventDefaults) // When the panel regains focus (e.g. returning from terminal), focus the prompt - const onWindowFocus = () => window.dispatchEvent(new Event("focusPrompt")) + // and clear any stale body styles left by Kobalte modal overlays (dropdowns/dialogs + // set pointer-events:none and overflow:hidden on body, but cleanup never runs if + // focus leaves the webview before the overlay closes). + const onWindowFocus = () => { + document.body.style.pointerEvents = "" + document.body.style.overflow = "" + window.dispatchEvent(new Event("focusPrompt")) + } window.addEventListener("focus", onWindowFocus) // When a session is created while on local, replace the current pending tab with the real session. @@ -407,6 +586,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 @@ -435,15 +619,34 @@ const AgentManagerContent: Component = () => { session.selectSession(ev.sessionId) } + if (msg.type === "agentManager.keybindings") { + const ev = msg as AgentManagerKeybindingsMessage + setKb(ev.bindings) + } + if (msg.type === "agentManager.state") { const state = msg as AgentManagerStateMessage setWorktrees(state.worktrees) setManagedSessions(state.sessions) + if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo) + if (!worktreesLoaded()) setWorktreesLoaded(true) + if (state.tabOrder) setWorktreeTabOrder(state.tabOrder) const current = session.currentSessionID() if (current) { const ms = state.sessions.find((s) => s.id === current) if (ms?.worktreeId) setSelection(ms.worktreeId) } + // Recover local tab order from persisted state + const localOrder = state.tabOrder?.[LOCAL] + if (localOrder && localSessionIDs().length > 0) { + const reordered = applyTabOrder( + localSessionIDs().map((id) => ({ id })), + localOrder, + ).map((item) => item.id) + setLocalSessionIDs(reordered) + } + // Recover sessions collapsed state from extension-persisted state + if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed) // Clear deleting state for worktrees that have been removed const ids = new Set(state.worktrees.map((wt) => wt.id)) setDeletingWorktrees((prev) => { @@ -451,6 +654,69 @@ const AgentManagerContent: Component = () => { return next.size === prev.size ? prev : next }) } + + // When a multi-version progress update arrives, mark newly created worktrees as loading + if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { + const ev = msg as unknown as AgentManagerMultiVersionProgressMessage + if (ev.status === "done" && ev.groupId) { + // Clear loading state for all worktrees in this group + setLoadingWorktrees((prev) => { + const next = new Set(prev) + for (const wt of worktrees()) { + if (wt.groupId === ev.groupId) next.delete(wt.id) + } + return next + }) + } + } + + // When state updates arrive, mark new grouped worktrees as loading + // (they were just created and haven't received their prompt yet) + if (msg.type === "agentManager.worktreeSetup") { + const ev = msg as AgentManagerWorktreeSetupMessage + if (ev.status === "ready" && ev.sessionId) { + const ms = managedSessions().find((s) => s.id === ev.sessionId) + const wt = ms?.worktreeId ? worktrees().find((w) => w.id === ms.worktreeId) : undefined + if (wt?.groupId) { + setLoadingWorktrees((prev) => new Set([...prev, wt.id])) + } + } + } + + // Handle initial message send for multi-version sessions. + // The extension creates the worktrees/sessions, then asks the webview + // to send the prompt through the normal KiloProvider sendMessage path. + // Once the message is sent, clear the loading state for that worktree. + if ((msg as { type: string }).type === "agentManager.sendInitialMessage") { + const ev = msg as unknown as AgentManagerSendInitialMessage + + // Set model and agent selections for this session so the UI reflects them + if (ev.providerID && ev.modelID) { + session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) + } + if (ev.agent) { + session.setSessionAgent(ev.sessionId, ev.agent) + } + + vscode.postMessage({ + type: "sendMessage", + text: ev.text, + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent, + files: ev.files, + }) + // Clear loading state — use worktreeId from the message directly + // to avoid race condition where managedSessions() hasn't updated yet + if (ev.worktreeId) { + setLoadingWorktrees((prev) => { + const next = new Set(prev) + next.delete(ev.worktreeId) + return next + }) + } + } }) onCleanup(() => { @@ -458,6 +724,7 @@ const AgentManagerContent: Component = () => { window.removeEventListener("keydown", preventDefaults) window.removeEventListener("focus", onWindowFocus) unsubCreate() + unsubSessions() unsub() }) }) @@ -465,16 +732,59 @@ 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() } }) + const handleConfigureSetupScript = () => { + vscode.postMessage({ type: "agentManager.configureSetupScript" }) + } + + const handleShowKeyboardShortcuts = () => { + const categories = buildShortcutCategories(kb()) + dialog.show(() => ( + +
+ + {(category) => ( +
+
{category.title}
+
+ + {(shortcut) => ( +
+ {shortcut.label} + + + {(token) => {token}} + + +
+ )} +
+
+
+ )} +
+
+
+ )) + } + const handleCreateWorktree = () => { vscode.postMessage({ type: "agentManager.createWorktree" }) } + // Advanced worktree dialog — opens a full dialog with prompt, versions, model, mode + const showAdvancedWorktreeDialog = () => { + dialog.show(() => dialog.close()} />) + } + const confirmDeleteWorktree = (worktreeId: string) => { const wt = worktrees().find((w) => w.id === worktreeId) if (!wt) return @@ -574,6 +884,53 @@ const AgentManagerContent: Component = () => { } } + // Drag-and-drop handlers for tab reordering + const tabIds = createMemo(() => activeTabs().map((s) => s.id)) + + const handleDragStart = (event: DragEvent) => { + const id = event.draggable?.id + if (typeof id === "string") setDraggingTab(id) + } + + const handleDragOver = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + if (typeof from !== "string" || typeof to !== "string") return + const sel = selection() + if (sel === LOCAL) { + setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev) + } else if (sel) { + setWorktreeTabOrder((prev) => { + const ids = applyTabOrder( + tabIds().map((id) => ({ id })), + prev[sel], + ).map((item) => item.id) + const reordered = reorderTabs(ids, from, to) + if (!reordered) return prev + return { ...prev, [sel]: reordered } + }) + } + } + + const handleDragEnd = () => { + setDraggingTab(undefined) + // Persist the new tab order to the extension + const sel = selection() + if (sel === LOCAL) { + const order = localSessionIDs().filter((id) => !isPending(id)) + if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: LOCAL, order }) + } else if (sel) { + const order = worktreeTabOrder()[sel] + if (order) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order }) + } + } + + const draggedTab = createMemo(() => { + const id = draggingTab() + if (!id) return undefined + return activeTabs().find((s) => s.id === id) + }) + // Close the currently active tab via keyboard shortcut. // If no tabs remain, fall through to close the selected worktree. const closeActiveTab = () => { @@ -625,7 +982,14 @@ const AgentManagerContent: Component = () => { return (
-
+
+ setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))} + /> {/* Local workspace item */} {/* WORKTREES section */} -
+
- + +
+ + + + + + Keyboard Shortcuts + + + + Worktree Setup Script + + + + +
+ + + + + + + + + New Worktree + + + + + New with Versions... + + + + +
+
+
- - {(wt) => ( -
selectWorktree(wt.id)} - > - - - {worktreeLabel(wt)} - - }> - handleDeleteWorktree(wt.id, e)} - /> - + +
+
+
+
- )} - - - + } + > + +
+ + Not a git repository +
+
+ + {(() => { + const [hoveredWt, setHoveredWt] = createSignal(null) + const [overClose, setOverClose] = createSignal(false) + return ( + + {(wt, idx) => { + const grouped = () => isGrouped(wt) + const start = () => isGroupStart(wt, idx()) + const end = () => isGroupEnd(wt, idx()) + const busy = () => deletingWorktrees().has(wt.id) || loadingWorktrees().has(wt.id) + const groupSize = () => { + if (!wt.groupId) return 0 + return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length + } + const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) + const navHint = () => { + const flat = [ + LOCAL as string, + ...sortedWorktrees().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 ( + <> + +
+ + {groupSize()} versions +
+
+ setHoveredWt(open ? wt.id : null)} + trigger={ +
selectWorktree(wt.id)} + > + + {worktreeLabel(wt)} + }> +
setOverClose(true)} + onMouseLeave={() => setOverClose(false)} + > + + handleDeleteWorktree(wt.id, e)} + /> + +
+
+
+ } + > +
+
+
+
BRANCH
+
{wt.branch}
+
{formatRelativeDate(wt.createdAt)}
+
+ + {navHint()} + +
+ +
+
+ Base + {wt.parentBranch} +
+ +
+
+ Sessions + {sessions().length} +
+
+ + + ) + }} + + ) + })()} + + + +
- {/* SESSIONS section (unassigned) */} -
-
- -
-
- - {(s) => ( - - )} - -
+ {/* SESSIONS section (unassigned) — collapsible */} +
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ } + > + + {(s) => ( + + )} + + +
+
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */} -
-
-
-
- - {(s) => { - const pending = isPending(s.id) - const active = () => - pending - ? s.id === activePendingId() && !session.currentSessionID() - : s.id === session.currentSessionID() - return ( - -
{ - if (pending) { - setActivePendingId(s.id) - session.clearCurrentSession() - } else { - setActivePendingId(undefined) - session.selectSession(s.id) - } - }} - onMouseDown={(e: MouseEvent) => handleTabMouseDown(s.id, e)} - > - {s.title || "Untitled"} - handleCloseTab(s.id, e)} + + + +
+
+
+
+ + + {(s) => { + const pending = isPending(s.id) + const active = () => + 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) + session.clearCurrentSession() + } else { + setActivePendingId(undefined) + session.selectSession(s.id) + } + }} + onMiddleClick={(e: MouseEvent) => handleTabMouseDown(s.id, e)} + onClose={(e: MouseEvent) => handleCloseTab(s.id, e)} /> -
- - ) - }} - + ) + }} + + +
+
-
-
- -
- + { - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) - }} + label="New session" + class="am-tab-add" + onClick={handleAddSession} /> - + +
+ + { + const id = session.currentSessionID() + if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) + }} + /> + +
-
+ + + {(tab) => ( +
+ {tab().title || "Untitled"} +
+ )} +
+
+ {/* Empty worktree state */} @@ -800,7 +1374,7 @@ const AgentManagerContent: Component = () => {
No sessions open
@@ -854,6 +1428,148 @@ const AgentManagerContent: Component = () => { ) } +// --------------------------------------------------------------------------- +// Advanced "New Worktree" dialog — prompt, versions, model, mode +// --------------------------------------------------------------------------- + +type VersionCount = 1 | 2 | 3 | 4 +const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] + +const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { + const vscode = useVSCode() + const session = useSession() + + const [prompt, setPrompt] = createSignal("") + const [versions, setVersions] = createSignal(2) + const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(null) + const [agent, setAgent] = createSignal(session.selectedAgent()) + const [starting, setStarting] = createSignal(false) + + let textareaRef: HTMLTextAreaElement | undefined + + onMount(() => { + requestAnimationFrame(() => { + if (!textareaRef) return + textareaRef.focus() + textareaRef.select() + }) + }) + + const canSubmit = () => prompt().trim().length > 0 && !starting() + + const handleSubmit = () => { + const text = prompt().trim() + if (!text || starting()) return + setStarting(true) + + const count = versions() + const sel = model() + const defaultAgent = session.agents()[0]?.name + const selectedAgent = agent() !== defaultAgent ? agent() : undefined + vscode.postMessage({ + type: "agentManager.createMultiVersion", + text, + versions: count, + providerID: sel?.providerID, + modelID: sel?.modelID, + agent: selectedAgent, + }) + + props.onClose() + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleSubmit() + } + } + + const adjustHeight = () => { + if (!textareaRef) return + textareaRef.style.height = "auto" + textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px` + } + + return ( + +
+ {/* Prompt input — reuses the same CSS as the sidebar chat input */} +
+
+
+