Merge branch 'dev' into fix/fix-orphan-sub-agents

This commit is contained in:
Marian Alexandru Alecu
2026-02-20 17:52:59 +02:00
committed by GitHub
118 changed files with 8947 additions and 975 deletions
+32
View File
@@ -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
+1
View File
@@ -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",
Generated
+3 -3
View File
@@ -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": {
+1
View File
@@ -162,6 +162,7 @@
unzip
gnutar
gzip
ripgrep
kilo-dev
kilo-install-bin
kilo-bin
+4 -4
View File
@@ -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="
}
}
@@ -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:
@@ -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);
}
}
}
}
+4 -2
View File
@@ -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;
}
+3 -1
View File
@@ -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",
+79 -140
View File
@@ -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<string> = 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<string>("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<void> {
if (!this.httpClient) {
if (this.cachedNotificationsMessage) {
this.postMessage(this.cachedNotificationsMessage)
}
return
}
try {
const notifications = await this.httpClient.getNotifications()
const existing = this.extensionContext?.globalState.get<string[]>("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<void> {
if (!this.extensionContext) return
const existing = this.extensionContext.globalState.get<string[]>("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<void> {
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)
}
}
@@ -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<void> | 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<void> {
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<WorktreeStateManager["addWorktree"]>
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<string, unknown>): Promise<null> {
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<string, string> = {}
// Global keybindings exposed to the shortcuts dialog
const globals: Record<string, string> = {
"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<void> {
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<void> {
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)
}
@@ -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<boolean> {
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<void> {
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<vscode.TerminalShellIntegration | undefined> {
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<void> {
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<void> {
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}`)
}
}
@@ -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<string | null> {
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<void> {
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<void> {
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}`)
}
}
@@ -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<void> {
@@ -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<string, Omit<Worktree, "id">>
sessions: Record<string, Omit<ManagedSession, "id">>
tabOrder?: Record<string, string[]>
sessionsCollapsed?: boolean
}
const STATE_FILE = "agent-manager.json"
@@ -44,6 +48,8 @@ export class WorktreeStateManager {
private readonly file: string
private worktrees = new Map<string, Worktree>()
private sessions = new Map<string, ManagedSession>()
private tabOrder: Record<string, string[]> = {}
private collapsed = false
private readonly log: (msg: string) => void
private saving: Promise<void> | 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<string, string[]> {
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 })
@@ -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"')
})
})
@@ -0,0 +1,34 @@
const KEY_SYMBOLS: Record<string, { mac: string; other: string }> = {
ctrl: { mac: "⌃", other: "Ctrl" },
cmd: { mac: "⌘", other: "Ctrl" },
shift: { mac: "⇧", other: "Shift" },
alt: { mac: "⌥", other: "Alt" },
}
const SPECIAL_KEYS: Record<string, string> = {
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("+")
}
@@ -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}"`
}
@@ -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!"
`
+2 -2
View File
@@ -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)
@@ -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<string, Provider>): Record<string, Provider> {
const normalized: Record<string, Provider> = {}
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<string, unknown>
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<typeof sessionToWebview> }
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
| 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
}
}
@@ -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<AutocompleteStatusBarStateProps>) {
@@ -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() {
@@ -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<string> {
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)
}
}
@@ -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")
}
@@ -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)
}
}
@@ -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<string, true> = 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)
}
/**
@@ -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 <COMPLETION> and </COMPLETION> tags
const completionMatch = fullResponse.match(/<COMPLETION>([\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(/<COMPLETION>([\s\S]*?)<\/COMPLETION>/i)
const suggestionText = completionMatch ? (completionMatch[1] || "").replace(/<\/?COMPLETION>/gi, "") : ""
const { text: suggestionText } = _parseAutocompleteResponse(response, "", "")
const fillInAtCursorSuggestion = processSuggestion(suggestionText)
@@ -0,0 +1,19 @@
import type { FillInAtCursorSuggestion } from "../types"
/**
* Parse a chat completion response and extract the text between <COMPLETION> 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(/<COMPLETION>([\s\S]*?)<\/COMPLETION>/i)
if (completionMatch) {
fimText = completionMatch[1] || ""
}
fimText = fimText.replace(/<\/?COMPLETION>/gi, "")
return { text: fimText, prefix, suffix }
}
@@ -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))
}
@@ -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<string, true>, 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)
}
}
}
@@ -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)
}
}
@@ -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
}
@@ -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()
}
@@ -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),
)
}
/**
@@ -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
}
}
@@ -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<KilocodeNotification[]> {
try {
return await this.request<KilocodeNotification[]>("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
}
}
@@ -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
}
}
@@ -26,6 +26,8 @@ export type {
McpRemoteConfig,
McpConfig,
Config,
KilocodeNotification,
KilocodeNotificationAction,
} from "./types"
export { ServerManager } from "./server-manager"
@@ -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 })
}
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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
@@ -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" })
}),
)
}
@@ -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<string, unknown> | undefined,
providerProperties: Record<string, unknown> | undefined,
): { event: string; properties: Record<string, unknown> } {
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")}`
}
@@ -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",
+2 -12
View File
@@ -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 `<!DOCTYPE html>
<html lang="en" data-theme="kilo-vscode">
@@ -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)
}
@@ -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")
})
})
@@ -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)
})
})
@@ -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))
})
})
@@ -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<string, true>()
insertWithLRUEviction(map, "k1", 5)
expect(map.has("k1")).toBe(true)
})
it("does not evict when under limit", () => {
const map = new Map<string, true>()
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<string, true>()
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<string, true>()
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<string, true>()
insertWithLRUEviction(map, "k1", 2)
insertWithLRUEviction(map, "k1", 2)
expect(map.size).toBe(1)
})
})
@@ -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")
})
})
@@ -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")
})
})
})
})
@@ -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()
})
})
@@ -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("<div", "\n", "html")).toBe(true)
})
it("does not skip in html after closing tag", () => {
expect(shouldSkipAutocomplete("</div>", "\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)
})
})
@@ -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")
})
})
@@ -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("\\")
})
})
@@ -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")
})
})
})
@@ -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("<COMPLETION>return 42;</COMPLETION>", 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("<COMPLETION>const x = 1;\nreturn x;</COMPLETION>", prefix, suffix)
expect(result.text).toBe("const x = 1;\nreturn x;")
})
it("handles case-insensitive tags", () => {
const result = parseAutocompleteResponse("<completion>return x;</completion>", prefix, suffix)
expect(result.text).toBe("return x;")
})
it("handles empty COMPLETION tags", () => {
const result = parseAutocompleteResponse("<COMPLETION></COMPLETION>", prefix, suffix)
expect(result.text).toBe("")
})
it("handles whitespace-only content in tags", () => {
const result = parseAutocompleteResponse("<COMPLETION> </COMPLETION>", prefix, suffix)
expect(result.text).toBe(" ")
})
it("handles response with prose before and after tags", () => {
const response = "Here is your completion:\n<COMPLETION>return value;</COMPLETION>\nHope that helps!"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("return value;")
})
it("removes accidentally captured tag remnants", () => {
const result = parseAutocompleteResponse("<COMPLETION><COMPLETION>inner</COMPLETION></COMPLETION>", 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("<COMPLETION>x</COMPLETION>", "pre", "suf")
expect(result.prefix).toBe("pre")
expect(result.suffix).toBe("suf")
})
})
@@ -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()
})
})
@@ -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("{{")
})
})
@@ -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)
})
})
@@ -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> = {}): 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> = {}): 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<string, Provider>)
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()
})
})
@@ -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")
})
})
@@ -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([])
})
})
@@ -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")
})
})
@@ -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")
})
})
@@ -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> = {}): 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)
})
})
@@ -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 }])
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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",
)
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -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")
})
})
@@ -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]+\}/)
})
})
})
@@ -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()
})
})
})
@@ -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)
}
})
})
@@ -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}`)
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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)
})
})
@@ -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 <name>` 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-<timestamp>", 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)
})
})
@@ -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)
})
})
})
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
}
.am-sidebar {
width: 260px;
position: relative;
min-width: 200px;
border-right: 1px solid var(--border-weak-base);
display: flex;
@@ -18,6 +18,10 @@
gap: 4px;
}
.am-sidebar > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
/* Fixed local workspace item */
.am-local-item {
@@ -103,6 +107,36 @@
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weak);
display: flex;
align-items: center;
gap: 2px;
}
/* Collapsible section toggle */
button.am-section-toggle {
all: unset;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 2px;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
button.am-section-toggle:hover .am-section-label {
color: var(--text-base);
}
.am-section-chevron {
flex-shrink: 0;
}
.am-section-actions {
display: flex;
align-items: center;
gap: 2px;
}
/* Worktree list */
@@ -112,11 +146,21 @@
flex-direction: column;
gap: 2px;
overflow-y: auto;
overflow-x: hidden;
max-height: 50vh;
}
.am-section-grow .am-worktree-list {
max-height: none;
flex: 1;
}
/* Worktree item — larger card style */
.am-worktree-list [data-slot="hover-card-trigger"] {
min-width: 0;
}
.am-worktree-item {
position: relative;
display: flex;
@@ -127,6 +171,8 @@
cursor: pointer;
font-size: var(--font-size-base);
color: var(--text-base);
min-width: 0;
width: 100%;
}
.am-worktree-item:hover {
@@ -173,6 +219,48 @@
flex-shrink: 0;
}
/* Grouped worktrees — visual grouping with header and left accent */
.am-wt-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px 2px;
margin-top: 6px;
color: var(--text-weaker);
}
.am-wt-group-header [data-component="icon"] {
color: var(--border-focus, #007fd4);
opacity: 0.7;
}
.am-wt-group-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.2px;
color: color-mix(in srgb, var(--border-focus, #007fd4) 80%, var(--text-weak));
}
.am-wt-grouped {
margin-left: 8px;
padding-left: 10px;
border-left: 2px solid color-mix(in srgb, var(--border-focus, #007fd4) 35%, transparent);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.am-wt-group-end {
margin-bottom: 6px;
}
.am-wt-grouped:hover {
border-left-color: color-mix(in srgb, var(--border-focus, #007fd4) 60%, transparent);
}
.am-wt-grouped.am-worktree-item-active {
border-left-color: var(--border-focus, #007fd4);
}
/* Session list */
.am-list {
@@ -323,9 +411,10 @@
position: relative;
display: flex;
min-width: 0;
flex: 1;
flex-shrink: 1;
align-items: stretch;
height: 100%;
overflow: hidden;
}
/* Fade indicators for overflow */
@@ -406,6 +495,29 @@
opacity: 1;
}
/* Drag-and-drop sortable tab wrapper */
.am-tab-sortable {
display: flex;
height: 100%;
touch-action: none;
}
.am-tab-dragging {
opacity: 0.25;
}
/* Drag overlay tab (follows the cursor) */
.am-tab-overlay {
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-sm);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: none;
color: var(--text-base);
}
.am-tab-add {
flex-shrink: 0;
align-self: center;
@@ -594,3 +706,405 @@
opacity: 0.6;
margin-left: 6px;
}
/* Split button (+ with dropdown arrow) */
.am-split-button {
display: flex;
align-items: center;
gap: 0;
}
.am-split-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 22px;
padding: 0;
border: none;
background: none;
color: var(--text-weak);
cursor: pointer;
border-radius: var(--radius-sm);
opacity: 0.6;
transition: all 120ms;
}
.am-split-arrow:hover {
opacity: 1;
background: var(--surface-inset-base-hover);
}
.am-split-arrow[data-expanded] {
opacity: 1;
background: var(--surface-inset-base-hover);
}
.am-split-menu {
min-width: 210px;
}
.am-split-menu [data-slot="dropdown-menu-item"] {
padding: 6px 10px;
}
.am-split-menu [data-slot="dropdown-menu-separator"] {
margin: 4px 0;
}
/* New Versioned Session Dialog */
.am-nv-dialog {
display: flex;
flex-direction: column;
gap: 14px;
padding: 0 20px 20px;
min-width: 460px;
}
/* Reuse am-prompt-input-container inside dialog — resizable from bottom edge */
.am-nv-dialog .am-prompt-input-container {
margin: 0;
resize: vertical;
overflow: hidden;
min-height: 120px;
max-height: 400px;
display: flex;
flex-direction: column;
}
.am-nv-dialog .am-prompt-input-wrapper {
flex: 1;
min-height: 0;
}
.am-nv-dialog .am-prompt-input-ghost-wrapper {
height: 100%;
}
.am-nv-dialog .am-prompt-input {
color: var(--vscode-input-foreground, var(--text-base));
resize: none;
min-height: 100%;
max-height: none;
}
.am-nv-config-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--text-weaker);
white-space: nowrap;
}
/* Version selector bar below the prompt */
.am-nv-version-bar {
display: flex;
align-items: center;
gap: 10px;
}
/* Version pills */
.am-nv-pills {
display: flex;
gap: 2px;
background: var(--surface-base);
border-radius: var(--radius-sm);
padding: 2px;
}
.am-nv-pill {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
border: none;
border-radius: calc(var(--radius-sm) - 1px);
background: transparent;
color: var(--text-weak);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 100ms;
}
.am-nv-pill:hover {
background: var(--surface-inset-base-hover);
color: var(--text-base);
}
.am-nv-pill-active {
background: var(--border-focus, #007fd4) !important;
color: #fff !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Version hint text */
.am-nv-version-hint {
font-size: 12px;
color: var(--text-weaker);
}
.am-nv-spinner {
width: 14px;
height: 14px;
}
/* HoverCard popover for worktree items */
.am-hover-card {
padding: 10px 12px;
min-width: 160px;
max-width: 240px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.am-hover-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.am-hover-card-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weaker);
line-height: 1.4;
}
.am-hover-card-branch {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
line-height: 1.4;
word-break: break-all;
}
.am-hover-card-meta {
font-size: 12px;
color: var(--text-weaker);
line-height: 1.4;
}
.am-hover-card-keybind {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 6px;
border-radius: 3px;
background: var(--surface-inset-base);
border: 1px solid var(--border-weak-base);
font-family: var(--font-family-sans);
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--text-weak);
white-space: nowrap;
}
.am-hover-card-divider {
height: 1px;
background: var(--border-weak-base);
margin: 6px 0;
}
.am-hover-card-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.am-hover-card-row-label {
font-size: 12px;
color: var(--text-weaker);
}
.am-hover-card-row-value {
font-size: 12px;
font-weight: 500;
color: var(--text-base);
}
/* Keyboard shortcuts dialog */
.am-shortcuts {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 24px 20px 24px;
max-height: 60vh;
overflow-y: auto;
}
.am-shortcuts-category {
display: flex;
flex-direction: column;
gap: 4px;
}
.am-shortcuts-category-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weaker);
padding: 0 0 4px 0;
}
.am-shortcuts-list {
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-sm);
overflow: hidden;
}
.am-shortcuts-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
gap: 16px;
}
.am-shortcuts-row + .am-shortcuts-row {
border-top: 1px solid var(--border-weak-base);
}
.am-shortcuts-label {
font-size: var(--font-size-base);
color: var(--text-base);
white-space: nowrap;
}
.am-shortcuts-keys {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.am-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
min-width: 22px;
padding: 0 6px;
border-radius: 4px;
background: var(--surface-inset-base);
border: 1px solid var(--border-weak-base);
font-family: var(--font-family-sans);
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--text-weak);
white-space: nowrap;
box-shadow: 0 1px 0 var(--border-weak-base);
}
/* Skeleton loading states */
@keyframes am-skeleton-pulse {
0%,
100% {
opacity: 0.12;
}
50% {
opacity: 0.28;
}
}
/* Not a git repo notice */
.am-not-git-notice {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
font-size: 12px;
color: var(--text-weak);
}
.am-skeleton-list {
display: flex;
flex-direction: column;
gap: 2px;
animation: am-fade-in 0.2s ease;
}
/* Worktree skeleton — matches .am-worktree-item layout */
.am-skeleton-wt {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
}
.am-skeleton-wt-icon {
width: 16px;
height: 16px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
.am-skeleton-wt-text {
height: 13px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon,
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text {
animation-delay: 0.15s;
}
/* Session skeleton — matches .am-item layout */
.am-skeleton-session {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
}
.am-skeleton-session-title {
height: 13px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
.am-skeleton-session-time {
height: 10px;
width: 52px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
.am-skeleton-session:nth-child(2) .am-skeleton-session-title,
.am-skeleton-session:nth-child(2) .am-skeleton-session-time {
animation-delay: 0.15s;
}
.am-skeleton-session:nth-child(3) .am-skeleton-session-title,
.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
animation-delay: 0.3s;
}
@@ -46,6 +46,34 @@ export function validateLocalSession(persisted: string | undefined, ids: string[
return persisted
}
/**
* Return the keybinding hint for an item adjacent to the active item.
* Only returns a hint when the item is exactly one step away in the flat list.
* Returns empty string for non-adjacent items or the active item itself.
*
* @param itemId - The item being hovered
* @param activeId - The currently selected/active item (or undefined for LOCAL)
* @param flatIds - The full ordered sidebar list (LOCAL first, then worktrees, then sessions)
* @param prev - Display string for "go up" (e.g. "⌘↑" or keybinding)
* @param next - Display string for "go down" (e.g. "⌘↓" or keybinding)
*/
export function adjacentHint(
itemId: string,
activeId: string | undefined,
flatIds: string[],
prev: string,
next: string,
): string {
if (!activeId || itemId === activeId) return ""
const activeIdx = flatIds.indexOf(activeId)
const itemIdx = flatIds.indexOf(itemId)
if (activeIdx === -1 || itemIdx === -1) return ""
const diff = itemIdx - activeIdx
if (diff === -1) return prev
if (diff === 1) return next
return ""
}
/**
* After removing a worktree, pick the nearest remaining sidebar neighbor.
* Order: the worktree just below the one above LOCAL.
@@ -0,0 +1,78 @@
/**
* Drag-and-drop sortable tab components for the agent manager tab bar.
*/
import { Component, onCleanup } from "solid-js"
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot } from "solid-js"
import type { SessionInfo } from "../src/types/messages"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
export const ConstrainDragYAxis: Component = () => {
const context = useDragDropContext()
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
const dispose = createRoot((dispose) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
})
onDragEnd(({ draggable }) => {
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
})
return dispose
})
onCleanup(dispose)
return null
}
/** Individual sortable tab wrapper using the `use:sortable` directive. */
export const SortableTab: Component<{
tab: SessionInfo
active: boolean
keybind?: string
closeKeybind?: string
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
}> = (props) => {
const sortable = createSortable(props.tab.id)
// Prevent tree-shaking of the directive reference used by `use:sortable`
void sortable
return (
// @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.tab.id}
>
<TooltipKeybind
title={props.tab.title || "Untitled"}
keybind={props.keybind ?? ""}
placement="bottom"
inactive={props.active}
>
<div
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
>
<span class="am-tab-label">{props.tab.title || "Untitled"}</span>
<TooltipKeybind title="Close" keybind={props.closeKeybind ?? ""} placement="bottom">
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close tab"
class="am-tab-close"
onClick={props.onClose}
/>
</TooltipKeybind>
</div>
</TooltipKeybind>
</div>
)
}
@@ -0,0 +1,62 @@
/**
* Pure tab-ordering logic for the agent manager.
*/
/**
* Reorder an array by moving the item at `from` to the position of `to`.
* Returns a new array, or undefined if either ID is not found or they are equal.
*/
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
if (from === to) return undefined
const fi = tabs.indexOf(from)
const ti = tabs.indexOf(to)
if (fi === -1 || ti === -1) return undefined
const result = [...tabs]
result.splice(fi, 1)
result.splice(ti, 0, from)
return result
}
/**
* Apply a custom ordering to a list of items.
*
* Items are returned in `order` sequence (skipping IDs not in `items`),
* followed by any items not present in `order`.
* Returns the original array unchanged if `order` is undefined or empty.
*/
export function applyTabOrder<T extends { id: string }>(items: T[], order: string[] | undefined): T[] {
if (!order || order.length === 0) return items
const lookup = new Map(items.map((item) => [item.id, item]))
const ordered: T[] = []
for (const id of order) {
const item = lookup.get(id)
if (item) {
ordered.push(item)
lookup.delete(id)
}
}
for (const item of lookup.values()) ordered.push(item)
return ordered
}
/**
* Find the title of the first item according to a custom order.
*
* Falls back to the first titled item in `items` if the order
* doesn't produce a match, then to `fallback`.
*/
export function firstOrderedTitle(
items: { id: string; title?: string }[],
order: string[] | undefined,
fallback: string,
): string {
if (order) {
const lookup = new Map(items.map((item) => [item.id, item]))
for (const id of order) {
const item = lookup.get(id)
if (item?.title) return item.title
}
}
const first = items.find((item) => item.title)
return first?.title || fallback
}
+8 -5
View File
@@ -18,6 +18,7 @@ import { SessionProvider, useSession } from "./context/session"
import { LanguageProvider } from "./context/language"
import { ChatView } from "./components/chat"
import SessionList from "./components/history/SessionList"
import { NotificationsProvider } from "./context/notifications"
import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2"
import "./styles/chat.css"
@@ -184,11 +185,13 @@ const App: Component = () => {
<CodeComponentProvider component={Code}>
<ProviderProvider>
<ConfigProvider>
<SessionProvider>
<DataBridge>
<AppContent />
</DataBridge>
</SessionProvider>
<NotificationsProvider>
<SessionProvider>
<DataBridge>
<AppContent />
</DataBridge>
</SessionProvider>
</NotificationsProvider>
</ConfigProvider>
</ProviderProvider>
</CodeComponentProvider>
@@ -10,6 +10,7 @@ import { TaskHeader } from "./TaskHeader"
import { MessageList } from "./MessageList"
import { PromptInput } from "./PromptInput"
import { QuestionDock } from "./QuestionDock"
import { KiloNotifications } from "./KiloNotifications"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
@@ -54,8 +55,13 @@ export const ChatView: Component<ChatViewProps> = (props) => {
return (
<div class="chat-view">
<TaskHeader />
<div class="chat-messages">
<MessageList onSelectSession={props.onSelectSession} />
<div class="chat-messages-wrapper">
<Show when={!id()}>
<KiloNotifications />
</Show>
<div class="chat-messages">
<MessageList onSelectSession={props.onSelectSession} />
</div>
</div>
<Show when={!props.readonly}>
@@ -0,0 +1,67 @@
import { Component, Show, createMemo, createSignal } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { useNotifications } from "../../context/notifications"
import { useVSCode } from "../../context/vscode"
export const KiloNotifications: Component = () => {
const { filteredNotifications, dismiss } = useNotifications()
const vscode = useVSCode()
const [index, setIndex] = createSignal(0)
const items = filteredNotifications
const total = () => items().length
const safeIndex = () => Math.min(index(), Math.max(0, total() - 1))
const current = createMemo(() => (total() === 0 ? undefined : items()[safeIndex()]))
const prev = () => setIndex((i) => (i - 1 + total()) % total())
const next = () => setIndex((i) => (i + 1) % total())
const handleAction = (url: string) => {
vscode.postMessage({ type: "openExternal", url })
}
const handleDismiss = () => {
const n = current()
if (!n) return
dismiss(n.id)
setIndex((i) => Math.min(i, Math.max(0, total() - 2)))
}
return (
<Show when={total() > 0}>
<div class="kilo-notifications">
<div class="kilo-notifications-card">
<div class="kilo-notifications-header">
<span class="kilo-notifications-title">{current()?.title}</span>
<IconButton size="small" variant="ghost" icon="close" onClick={handleDismiss} title="Dismiss" />
</div>
<p class="kilo-notifications-message">{current()?.message}</p>
<div class="kilo-notifications-footer">
<Show when={total() > 1}>
<div class="kilo-notifications-nav">
<button class="kilo-notifications-nav-btn" onClick={prev} title="Previous">
<Icon name="arrow-left" size="small" />
</button>
<span class="kilo-notifications-nav-count">
{safeIndex() + 1} / {total()}
</span>
<button class="kilo-notifications-nav-btn" onClick={next} title="Next">
<Icon name="arrow-right" size="small" />
</button>
</div>
</Show>
<Show when={current()?.action}>
{(action) => (
<Button variant="primary" size="small" onClick={() => handleAction(action().actionURL)}>
{action().actionText}
</Button>
)}
</Show>
</div>
</div>
</div>
</Show>
)
}
@@ -14,22 +14,13 @@ import { useProvider, EnrichedModel } from "../../context/provider"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import type { ModelSelection } from "../../types/messages"
import { KILO_GATEWAY_ID, providerSortKey, isFree, buildTriggerLabel } from "./model-selector-utils"
interface ModelGroup {
providerName: string
models: EnrichedModel[]
}
const KILO_GATEWAY_ID = "kilo"
/** Provider display order — popular providers sort first */
const PROVIDER_ORDER = [KILO_GATEWAY_ID, "anthropic", "openai", "google"]
function providerSortKey(providerID: string): number {
const idx = PROVIDER_ORDER.indexOf(providerID.toLowerCase())
return idx >= 0 ? idx : PROVIDER_ORDER.length
}
// ---------------------------------------------------------------------------
// Reusable base component
// ---------------------------------------------------------------------------
@@ -168,10 +159,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
})
}
function isFree(model: EnrichedModel): boolean {
return model.inputPrice === 0
}
function isSelected(model: EnrichedModel): boolean {
const sel = selectedModel()
return sel !== undefined && sel.providerID === model.providerID && sel.id === model.id
@@ -182,21 +169,19 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return flatFiltered().indexOf(model) + clearOffset()
}
const triggerLabel = () => {
const sel = selectedModel()
if (sel) {
return sel.name
}
// Fallback: raw selection exists but findModel didn't resolve — show raw IDs
const raw = props.value
if (raw?.providerID && raw?.modelID) {
return raw.providerID === KILO_GATEWAY_ID ? raw.modelID : `${raw.providerID} / ${raw.modelID}`
}
if (props.allowClear) {
return props.clearLabel ?? language.t("dialog.model.notSet")
}
return hasProviders() ? language.t("dialog.model.select.title") : language.t("dialog.model.noProviders")
}
const triggerLabel = () =>
buildTriggerLabel(
selectedModel()?.name,
props.value,
props.allowClear ?? false,
props.clearLabel ?? "",
hasProviders(),
{
select: language.t("dialog.model.select.title"),
noProviders: language.t("dialog.model.noProviders"),
notSet: language.t("dialog.model.notSet"),
},
)
return (
<Popover
@@ -15,6 +15,7 @@ import { ModelSelector } from "./ModelSelector"
import { ModeSwitcher } from "./ModeSwitcher"
import { useFileMention } from "../../hooks/useFileMention"
import { useImageAttachments } from "../../hooks/useImageAttachments"
import { fileName, dirName, buildHighlightSegments } from "./prompt-input-utils"
const AUTOCOMPLETE_DEBOUNCE_MS = 500
const MIN_TEXT_LENGTH = 3
@@ -150,43 +151,6 @@ export const PromptInput: Component = () => {
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
}
const buildHighlightSegments = (val: string) => {
const paths = mention.mentionedPaths()
if (paths.size === 0) return [{ text: val, highlight: false }]
const segments: { text: string; highlight: boolean }[] = []
let remaining = val
while (remaining.length > 0) {
let earliest = -1
let earliestPath = ""
for (const path of paths) {
const token = `@${path}`
const idx = remaining.indexOf(token)
if (idx !== -1 && (earliest === -1 || idx < earliest)) {
earliest = idx
earliestPath = path
}
}
if (earliest === -1) {
segments.push({ text: remaining, highlight: false })
break
}
if (earliest > 0) {
segments.push({ text: remaining.substring(0, earliest), highlight: false })
}
const token = `@${earliestPath}`
segments.push({ text: token, highlight: true })
remaining = remaining.substring(earliest + token.length)
}
return segments
}
const handleInput = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement
const val = target.value
@@ -263,14 +227,6 @@ export const PromptInput: Component = () => {
if (textareaRef) textareaRef.style.height = "auto"
}
const fileName = (path: string) => path.replaceAll("\\", "/").split("/").pop() ?? path
const dirName = (path: string) => {
const parts = path.replaceAll("\\", "/").split("/")
if (parts.length <= 1) return ""
const dir = parts.slice(0, -1).join("/")
return dir.length > 30 ? `…/${parts.slice(-3, -1).join("/")}` : dir
}
return (
<div
class="prompt-input-container"
@@ -327,7 +283,7 @@ export const PromptInput: Component = () => {
<div class="prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true">
<Index each={buildHighlightSegments(text())}>
<Index each={buildHighlightSegments(text(), mention.mentionedPaths())}>
{(seg) => (
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
<span class="prompt-input-file-mention">{seg().text}</span>
@@ -12,6 +12,7 @@ import { Icon } from "@kilocode/kilo-ui/icon"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import type { QuestionRequest } from "../../types/messages"
import { toggleAnswer, buildSubtitleText } from "./question-dock-utils"
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
const session = useSession()
@@ -46,11 +47,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
return store.answers[store.tab]?.includes(value) ?? false
})
const subtitle = createMemo(() => {
const count = questions().length
if (count === 0) return ""
return `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
})
const subtitle = createMemo(() =>
buildSubtitleText(questions().length, language.t("ui.common.question.one"), language.t("ui.common.question.other")),
)
const reply = (answers: string[][]) => {
if (store.sending) return
@@ -88,12 +87,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
const toggle = (answer: string) => {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const next = toggleAnswer(store.answers[store.tab] ?? [], answer)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
@@ -0,0 +1,31 @@
import type { ModelSelection } from "../../types/messages"
import type { EnrichedModel } from "../../context/provider"
export const KILO_GATEWAY_ID = "kilo"
export const PROVIDER_ORDER = [KILO_GATEWAY_ID, "anthropic", "openai", "google"]
export function providerSortKey(providerID: string, order = PROVIDER_ORDER): number {
const idx = order.indexOf(providerID.toLowerCase())
return idx >= 0 ? idx : order.length
}
export function isFree(model: Pick<EnrichedModel, "inputPrice">): boolean {
return model.inputPrice === 0
}
export function buildTriggerLabel(
resolvedName: string | undefined,
raw: ModelSelection | null,
allowClear: boolean,
clearLabel: string,
hasProviders: boolean,
labels: { select: string; noProviders: string; notSet: string },
): string {
if (resolvedName) return resolvedName
if (raw?.providerID && raw?.modelID) {
return raw.providerID === KILO_GATEWAY_ID ? raw.modelID : `${raw.providerID} / ${raw.modelID}`
}
if (allowClear) return clearLabel || labels.notSet
return hasProviders ? labels.select : labels.noProviders
}
@@ -0,0 +1,46 @@
export function fileName(path: string): string {
return path.replaceAll("\\", "/").split("/").pop() ?? path
}
export function dirName(path: string): string {
const parts = path.replaceAll("\\", "/").split("/")
if (parts.length <= 1) return ""
const dir = parts.slice(0, -1).join("/")
return dir.length > 30 ? `…/${parts.slice(-3, -1).join("/")}` : dir
}
export function buildHighlightSegments(val: string, paths: Set<string>): { text: string; highlight: boolean }[] {
if (paths.size === 0) return [{ text: val, highlight: false }]
const segments: { text: string; highlight: boolean }[] = []
let remaining = val
while (remaining.length > 0) {
let earliest = -1
let earliestPath = ""
for (const path of paths) {
const token = `@${path}`
const idx = remaining.indexOf(token)
if (idx !== -1 && (earliest === -1 || idx < earliest)) {
earliest = idx
earliestPath = path
}
}
if (earliest === -1) {
segments.push({ text: remaining, highlight: false })
break
}
if (earliest > 0) {
segments.push({ text: remaining.substring(0, earliest), highlight: false })
}
const token = `@${earliestPath}`
segments.push({ text: token, highlight: true })
remaining = remaining.substring(earliest + token.length)
}
return segments
}
@@ -0,0 +1,12 @@
export function toggleAnswer(existing: string[], answer: string): string[] {
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
return next
}
export function buildSubtitleText(count: number, singular: string, plural: string): string {
if (count === 0) return ""
return `${count} ${count > 1 ? plural : singular}`
}

Some files were not shown because too many files have changed in this diff Show More