diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 2603f571656..74f408e0c8d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,6 +1,7 @@ import * as path from "path" import * as vscode from "vscode" import { z } from "zod" +import { isAbsolutePath } from "./path-utils" import type { KiloClient, Session, @@ -255,6 +256,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sessionDirectories.delete(sessionId) } + /** Return the currently active session ID, if any. */ + public getCurrentSessionId(): string | undefined { + return this.currentSession?.id ?? undefined + } + /** * Re-fetch and send the full session list to the webview. * Called by AgentManagerProvider after worktree recovery completes. @@ -761,13 +767,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (abort.signal.aborted) return // Update currentSession so fallback logic in handleSendMessage/handleAbort - // references the correct session after switching to a historical session. + // references the correct session after switching. loadMessages is the + // canonical "user switched to this session" signal, so always update — + // the old guard `this.currentSession.id === sessionID` prevented updates + // when switching between different sessions. // Non-blocking: don't let a failure here prevent messages from loading. // 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs. this.client.session .get({ sessionID, directory: workspaceDir }) .then((result) => { - if (result.data && (!this.currentSession || this.currentSession.id === sessionID)) { + if (result.data && !abort.signal.aborted) { this.currentSession = result.data } }) @@ -1613,9 +1622,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.log("[Kilo New] KiloProvider: 🔐 Login successful") - await this.client.global.dispose().catch((e: unknown) => - console.warn("[Kilo New] KiloProvider: global.dispose() after login failed:", e), - ) + await this.client.global + .dispose() + .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: global.dispose() after login failed:", e)) // Step 4: Fetch profile and push to webview const { data: profileData } = await this.client.kilo.profile(undefined, { throwOnError: true }) @@ -1662,9 +1671,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } - await this.client.global.dispose().catch((e: unknown) => - console.warn("[Kilo New] KiloProvider: global.dispose() after org switch failed:", e), - ) + await this.client.global + .dispose() + .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: global.dispose() after org switch failed:", e)) // Org switch succeeded — refresh profile and providers independently (best-effort) try { @@ -1682,12 +1691,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** * Handle openFile request from the webview — open a file in the VS Code editor. + * Resolves relative paths against the current session's directory (which may be + * a worktree path registered via setSessionDirectory), falling back to workspace root. + * Absolute paths (Unix `/…` or Windows `C:\…`) are used as-is. */ private handleOpenFile(filePath: string, line?: number, column?: number): void { - const absolute = /^(?:\/|[a-zA-Z]:[\\/])/.test(filePath) - const uri = absolute + const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) - : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory()), filePath) + : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath) vscode.workspace.openTextDocument(uri).then( (doc) => { const options: vscode.TextDocumentShowOptions = { preview: true } @@ -1719,10 +1730,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper data: null, }) - await this.client.global.dispose().catch((e: unknown) => - console.warn("[Kilo New] KiloProvider: global.dispose() after logout failed:", e), - ) - + await this.client.global + .dispose() + .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: global.dispose() after logout failed:", e)) } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Logout failed:", error) this.postMessage({ @@ -1730,8 +1740,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message: getErrorMessage(error) || "Failed to logout", }) } - - } /** @@ -1858,7 +1866,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Refresh provider and agent lists when the server signals a state disposal if (event.type === "server.instance.disposed" || event.type === "global.disposed") { - void this.reloadAfterAuthChange(); + void this.reloadAfterAuthChange() return } @@ -2118,14 +2126,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }, this.cachedLegacyData?.settings, ) + + // Dispose all instances after migration + // Reloading the data will be handled once the server replies with a global.disposed event + await this.client.global + .dispose() + .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: global.dispose() after migration failed:", e)) + // Only mark as completed if at least one item succeeded — if everything failed // the user can still re-run migration via Settings → About. const anySuccess = results.some((r) => r.status === "success") + if (anySuccess) { await MigrationService.setMigrationStatus(this.extensionContext, "completed") } - // Refresh providers so webview immediately sees the newly-migrated API keys - await this.fetchAndSendProviders() + this.postMessage({ type: "legacyMigrationComplete", results }) } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Migration failed", error) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 40042f02d9d..b8b1275adb5 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,10 +4,12 @@ import * as path from "path" import type { KiloClient, Session, FileDiff } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" +import { isAbsolutePath } from "../path-utils" import { KiloProvider } from "../KiloProvider" import { buildWebviewHtml } from "../utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager } from "./WorktreeStateManager" +import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller" import { GitOps, type ApplyConflict } from "./GitOps" import { versionedName } from "./branch-name" @@ -52,6 +54,10 @@ export class AgentManagerProvider implements vscode.Disposable { private cachedWorktreeStats: Record | undefined private cachedLocalStats: Record | undefined private applyingWorktreeId: string | undefined + /** Session ID most recently loaded via a `loadMessages` message from the webview. + * Updated synchronously — unlike KiloProvider.currentSession which depends on + * an async `session.get` round-trip and can be stale during rapid tab switches. */ + private activeSessionId: string | undefined constructor( private readonly extensionUri: vscode.Uri, @@ -276,6 +282,12 @@ export class AgentManagerProvider implements vscode.Disposable { return null } + if (type === "agentManager.setDefaultBaseBranch") { + const branch = normalizeBaseBranch(msg.branch as string | undefined) + this.state?.setDefaultBaseBranch(branch) + this.pushState() + return null + } if (type === "agentManager.requestExternalWorktrees") { void this.onRequestExternalWorktrees() return null @@ -327,17 +339,37 @@ export class AgentManagerProvider implements vscode.Disposable { if (type === "agentManager.openFile" && typeof msg.sessionId === "string" && typeof msg.filePath === "string") { const line = typeof msg.line === "number" ? msg.line : undefined - this.openWorktreeFile(msg.sessionId, msg.filePath, line) + const column = typeof msg.column === "number" ? msg.column : undefined + this.openWorktreeFile(msg.sessionId, msg.filePath, line, column) return null } - // When switching sessions, show existing terminal if one is open + // Intercept generic "openFile" from DataBridge (markdown links, tool subtitle clicks) + // and route through worktree-aware resolution — but only for worktree sessions. + // Local sessions fall through to KiloProvider which resolves against workspace root. + // Uses activeSessionId (set synchronously by loadMessages) rather than + // KiloProvider.currentSession which can be stale during rapid tab switches. + if (type === "openFile" && typeof msg.filePath === "string") { + const sessionId = this.activeSessionId + const state = this.getStateManager() + if (sessionId && state?.directoryFor(sessionId)) { + const line = typeof msg.line === "number" ? msg.line : undefined + const column = typeof msg.column === "number" ? msg.column : undefined + this.openWorktreeFile(sessionId, msg.filePath, line, column) + return null + } + } + + // Track the active session synchronously so worktree-aware file resolution + // uses the correct session even before KiloProvider's async session.get completes. if (type === "loadMessages" && typeof msg.sessionID === "string") { + this.activeSessionId = msg.sessionID this.terminalManager.syncOnSessionSwitch(msg.sessionID) } - // After clearSession, re-register worktree sessions so SSE events keep flowing + // After clearSession, clear active tracking and re-register worktree sessions if (type === "clearSession") { + this.activeSessionId = undefined void Promise.resolve().then(() => { if (!this.provider || !this.state) return for (const id of this.state.worktreeSessionIds()) { @@ -361,6 +393,31 @@ export class AgentManagerProvider implements vscode.Disposable { // Shared helpers // --------------------------------------------------------------------------- + /** Resolve the effective base branch using the configured default, explicit override, and existence check. */ + private async resolveBaseBranch( + manager: WorktreeManager, + state: WorktreeStateManager, + explicit?: string, + ): Promise { + const configured = state.getDefaultBaseBranch() + if (!configured && !explicit) return undefined + + const configuredExists = configured ? await manager.branchExists(configured) : false + const result = chooseBaseBranch({ explicit, configured, configuredExists }) + + if (result.stale) { + this.clearStaleDefaultBaseBranch(state, result.stale) + } + return result.branch + } + + /** Reset a stale default base branch and notify the webview. */ + private clearStaleDefaultBaseBranch(state: WorktreeStateManager, stale: string): void { + this.log(`Default base branch "${stale}" no longer exists, clearing`) + state.setDefaultBaseBranch(undefined) + this.pushState() + } + /** Create a git worktree on disk and register it in state. Returns null on failure. */ private async createWorktreeOnDisk(opts?: { groupId?: string @@ -386,11 +443,16 @@ export class AgentManagerProvider implements vscode.Disposable { this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Creating git worktree..." }) + // Resolve effective base branch using configured default + const effectiveBase = opts?.existingBranch + ? undefined + : await this.resolveBaseBranch(manager, state, opts?.baseBranch) + let result: CreateWorktreeResult try { result = await manager.createWorktree({ prompt: opts?.name || "kilo", - baseBranch: opts?.baseBranch, + baseBranch: effectiveBase ?? opts?.baseBranch, branchName: opts?.branchName, existingBranch: opts?.existingBranch, }) @@ -915,10 +977,23 @@ export class AgentManagerProvider implements vscode.Disposable { try { const result = await manager.listBranches() const checkedOut = await manager.checkedOutBranches() - const filtered = result.branches.filter((b) => !checkedOut.has(b.name)) + + // Include isCheckedOut flag on each branch — let the webview decide how to filter + const branches = result.branches.map((b) => ({ + ...b, + isCheckedOut: checkedOut.has(b.name), + })) + + // Validate configured default branch still exists + const state = this.getStateManager() + const configured = state?.getDefaultBaseBranch() + if (configured && !branches.some((b) => b.name === configured)) { + this.clearStaleDefaultBaseBranch(state!, configured) + } + this.postToWebview({ type: "agentManager.branches", - branches: filtered, + branches, defaultBranch: result.defaultBranch, }) } catch (error) { @@ -1311,7 +1386,8 @@ export class AgentManagerProvider implements vscode.Disposable { if (!manager) return try { const branch = await manager.currentBranch() - this.postToWebview({ type: "agentManager.repoInfo", branch }) + const defaultBranch = await manager.defaultBranch() + this.postToWebview({ type: "agentManager.repoInfo", branch, defaultBranch }) } catch (error) { this.log(`Failed to get current branch: ${error}`) } @@ -1384,6 +1460,7 @@ export class AgentManagerProvider implements vscode.Disposable { sessionsCollapsed: state.getSessionsCollapsed(), reviewDiffStyle: state.getReviewDiffStyle(), isGitRepo: true, + defaultBaseBranch: state.getDefaultBaseBranch(), }) this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined) @@ -1548,20 +1625,35 @@ export class AgentManagerProvider implements vscode.Disposable { void vscode.commands.executeCommand("vscode.openFolder", uri, true) } - /** Open a file from a worktree or local session in the VS Code editor. */ - private openWorktreeFile(sessionId: string, relativePath: string, line?: number): void { + /** Open a file from a worktree or local session in the VS Code editor. + * Absolute paths (Unix `/…` or Windows `C:\…`) are opened directly. + * Relative paths are resolved against the session's worktree directory + * (or workspace root for local sessions) with symlink-traversal protection. */ + private openWorktreeFile(sessionId: string, filePath: string, line?: number, column?: number): void { + if (isAbsolutePath(filePath)) { + const uri = vscode.Uri.file(filePath) + const options: vscode.TextDocumentShowOptions = { preview: true } + if (line !== undefined && line > 0) { + const col = column !== undefined && column > 0 ? column - 1 : 0 + options.selection = new vscode.Range(new vscode.Position(line - 1, col), new vscode.Position(line - 1, col)) + } + vscode.workspace.openTextDocument(uri).then( + (doc) => vscode.window.showTextDocument(doc, options), + (err) => console.error("[Kilo New] AgentManagerProvider: Failed to open file:", uri.fsPath, err), + ) + return + } const state = this.getStateManager() if (!state) return const session = state.getSession(sessionId) - if (!session) return - const base = session.worktreeId ? state.getWorktree(session.worktreeId)?.path : this.getWorkspaceRoot() + const base = session?.worktreeId ? state.getWorktree(session.worktreeId)?.path : this.getWorkspaceRoot() if (!base) return // Resolve real paths to prevent symlink traversal and normalize for // consistent comparison on both Unix and Windows. let resolved: string try { const root = fs.realpathSync(base) - resolved = fs.realpathSync(path.resolve(base, relativePath)) + resolved = fs.realpathSync(path.resolve(base, filePath)) // Directory-boundary check: append path.sep so "/foo/bar" won't match "/foo/bar2/..." if (resolved !== root && !resolved.startsWith(root + path.sep)) return } catch (err) { @@ -1569,11 +1661,13 @@ export class AgentManagerProvider implements vscode.Disposable { return } const uri = vscode.Uri.file(resolved) + const options: vscode.TextDocumentShowOptions = { preview: true } const target = Math.max(1, Math.floor(line ?? 1)) - const pos = new vscode.Position(target - 1, 0) - const selection = new vscode.Range(pos, pos) + const col = column !== undefined && column > 0 ? column - 1 : 0 + const pos = new vscode.Position(target - 1, col) + options.selection = new vscode.Range(pos, pos) vscode.workspace.openTextDocument(uri).then( - (doc) => vscode.window.showTextDocument(doc, { preview: true, selection }), + (doc) => vscode.window.showTextDocument(doc, options), (err) => console.error("[Kilo New] AgentManagerProvider: Failed to open file:", uri.fsPath, err), ) } diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index d94b1269b09..6fe353bd7b5 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -62,6 +62,7 @@ export class GitOps { return this.runGit(args, cwd) } + /** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */ async currentBranch(cwd: string): Promise { return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "") } @@ -87,6 +88,7 @@ export class GitOps { return "origin" } + /** Resolve the upstream tracking ref for `branch`, or `undefined` if none is set. Note: the `@{upstream}` check uses the current HEAD, not `branch`. */ async resolveTrackingBranch(cwd: string, branch: string): Promise { const upstream = await this.raw(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd).catch(() => "") if (upstream) return upstream @@ -139,6 +141,7 @@ export class GitOps { return job } + /** Return the set of worktree paths for the repo, excluding bare entries. */ async listWorktreePaths(cwd: string): Promise> { const raw = await this.raw(["worktree", "list", "--porcelain"], cwd) const paths = new Set() @@ -151,12 +154,13 @@ export class GitOps { /** * Compute working-tree stats (staged + unstaged + untracked) without requiring - * a remote or base branch — mirrors the superset approach of running - * `git diff --numstat` and `git ls-files --others`. + * a remote or base branch. Combines `git diff HEAD --numstat` for tracked + * changes with `git ls-files --others` for new files. + * + * Returns aggregate file count, additions, and deletions across the working tree. */ async workingTreeStats(cwd: string): Promise<{ files: number; additions: number; deletions: number }> { - // Staged + unstaged changes relative to HEAD (like superset's dual - // git diff --cached --numstat + git diff --numstat, combined). + // Single diff against HEAD captures both staged and unstaged changes. const [numstat, untracked] = await Promise.all([ this.raw(["diff", "HEAD", "--numstat"], cwd).catch(() => ""), this.raw(["ls-files", "--others", "--exclude-standard"], cwd).catch(() => ""), @@ -177,8 +181,8 @@ export class GitOps { ) : { files: 0, additions: 0, deletions: 0 } - // Count lines in untracked files as additions (like superset's - // applyUntrackedLineCount). Cap at 1MB to avoid reading huge binaries. + // Count lines in untracked files as additions. Cap at 1MB to avoid + // reading large binary files into memory. if (!untracked) return tracked const paths = untracked.split("\n").filter((line) => line.trim()) @@ -205,9 +209,9 @@ export class GitOps { } /** - * Count commits ahead and behind in a single `rev-list --left-right --count` - * call (like superset's approach). Falls back through upstream → remote/branch - * → remote/parentBranch → parentBranch. + * Count commits ahead and behind using `rev-list --left-right --count`. + * Tries the best available ref in order: upstream tracking branch → + * remote/branch → remote/parentBranch → local parentBranch. */ async aheadBehind(cwd: string, parentBranch: string): Promise<{ ahead: number; behind: number }> { const upstream = await this.raw(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd).catch(() => "") @@ -241,6 +245,10 @@ export class GitOps { return { ahead, behind } } + /** + * Build a binary-safe patch of all working-tree changes relative to the + * merge-base with `baseBranch`. Optionally scoped to `selectedFiles`. + */ async buildWorktreePatch(sourcePath: string, baseBranch: string, selectedFiles?: string[]): Promise { const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), "kilo-apply-")) const index = nodePath.join(tmp, "index") diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 59d50dff70f..231dd842c5c 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -8,10 +8,10 @@ import * as path from "path" import * as fs from "fs" -import * as cp from "child_process" import simpleGit, { type SimpleGit } from "simple-git" import { generateBranchName, sanitizeBranchName } from "./branch-name" import type { GitOps } from "./GitOps" +import { execWithShellEnv } from "./shell-env" import { parsePRUrl, localBranchName, @@ -37,10 +37,23 @@ export interface WorktreeInfo { sessionId?: string } +export type StartPointSource = "remote" | "local-tracking" | "local-branch" | "fallback" + +export interface StartPointResult { + ref: string + branch: string + source: StartPointSource + warning?: string +} + +export type WorktreeProgressStep = "syncing" | "verifying" | "fetching" | "creating" + export interface CreateWorktreeResult { branch: string path: string parentBranch: string + startPointSource: StartPointSource + startPointWarning?: string } export interface ExternalWorktreeItem { @@ -97,6 +110,7 @@ export class WorktreeManager { existingBranch?: string baseBranch?: string branchName?: string + onProgress?: (step: WorktreeProgressStep, message: string, detail?: string) => void }): Promise { return this.withGitLock(() => this.createWorktreeImpl(params)) } @@ -106,6 +120,7 @@ export class WorktreeManager { existingBranch?: string baseBranch?: string branchName?: string + onProgress?: (step: WorktreeProgressStep, message: string, detail?: string) => void }): Promise { const repo = await this.git.checkIsRepo() if (!repo) @@ -113,20 +128,39 @@ export class WorktreeManager { "This folder is not a git repository. Initialize a repository or open a git project to use worktrees.", ) + // Git LFS Pre-flight Check + if (await this.repoUsesLfs()) { + if (!(await this.checkLfsAvailable())) { + throw new Error( + "This repository uses Git LFS, but git-lfs was not found. Please install Git LFS to use this repository.", + ) + } + } + await this.ensureDir() await this.ensureGitExclude() - const parent = params.baseBranch || (await this.currentBranch()) + // Resolve start point (parent branch) + let parent: string + let startPoint: StartPointResult | undefined - // Validate baseBranch exists if explicitly provided - if (params.baseBranch) { - const exists = await this.branchExists(params.baseBranch) - if (!exists) throw new Error(`Base branch "${params.baseBranch}" does not exist`) - // Check if the base branch is a remote-only branch and fetch it - const branches = await this.git.branch() - if (!branches.all.includes(params.baseBranch) && branches.all.includes(`remotes/origin/${params.baseBranch}`)) { - await this.git.fetch("origin", params.baseBranch) + if (params.existingBranch) { + // Existing branch provided directly + parent = params.existingBranch + startPoint = { + ref: params.existingBranch, + branch: params.existingBranch, + source: "local-branch", } + } else { + // Resolve best start point for new branch + const requestedBase = params.baseBranch || (await this.defaultBranch()) + params.onProgress?.("verifying", `Resolving start point: ${requestedBase}`) + + startPoint = await this.resolveStartPoint(requestedBase, params.onProgress, { + allowFallback: !params.baseBranch, // Only fallback if user didn't explicitly request a specific base + }) + parent = startPoint.branch } const sanitized = params.branchName ? sanitizeBranchName(params.branchName) : undefined @@ -145,12 +179,15 @@ export class WorktreeManager { await this.removeWorktreeImpl(worktreePath) } + params.onProgress?.("creating", `Creating worktree for ${branch}...`) + + // Dereference to commit SHA to prevent upstream tracking for new branches + const startRef = params.existingBranch ? undefined : `${startPoint.ref}^{commit}` + try { const args = params.existingBranch ? ["worktree", "add", worktreePath, branch] - : params.baseBranch - ? ["worktree", "add", "-b", branch, worktreePath, params.baseBranch] - : ["worktree", "add", "-b", branch, worktreePath] + : ["worktree", "add", "-b", branch, worktreePath, startRef!] await this.git.raw(args) } catch (error) { const msg = error instanceof Error ? error.message : String(error) @@ -167,14 +204,20 @@ export class WorktreeManager { branch = `${branch}-${Date.now()}` const retryDir = branch.replace(/\//g, "-") worktreePath = path.join(this.dir, retryDir) - const retryArgs = params.baseBranch - ? ["worktree", "add", "-b", branch, worktreePath, params.baseBranch] - : ["worktree", "add", "-b", branch, worktreePath] + const retryArgs = params.existingBranch + ? ["worktree", "add", worktreePath, branch] + : ["worktree", "add", "-b", branch, worktreePath, startRef!] await this.git.raw(retryArgs) } this.log(`Created worktree: ${worktreePath} (branch: ${branch}, base: ${parent})`) - return { branch, path: worktreePath, parentBranch: parent } + return { + branch, + path: worktreePath, + parentBranch: parent, + startPointSource: startPoint.source, + startPointWarning: startPoint.warning, + } } /** @@ -385,6 +428,125 @@ export class WorktreeManager { } } + async resolveStartPoint( + branch: string, + onProgress?: (step: WorktreeProgressStep, message: string, detail?: string) => void, + opts?: { allowFallback?: boolean }, + ): Promise { + const { allowFallback = true } = opts || {} + + // 1. Remote fetch + if (await this.hasOriginRemote()) { + onProgress?.("fetching", `Fetching origin/${branch}...`) + try { + await this.git.fetch("origin", branch) + if (await this.refExistsLocally(`origin/${branch}`)) { + return { + ref: `origin/${branch}`, + branch: branch, + source: "remote", + } + } + } catch (e) { + this.log(`Failed to fetch origin/${branch}: ${e}`) + } + } + + // 2. Stale local tracking ref (offline fallback) + if (await this.refExistsLocally(`origin/${branch}`)) { + return { + ref: `origin/${branch}`, + branch: branch, + source: "local-tracking", + warning: "Used stale remote tracking branch (fetch failed)", + } + } + + // 3. Local branch + if (await this.refExistsLocally(branch)) { + return { + ref: branch, + branch: branch, + source: "local-branch", + } + } + + // 4. Derived fallback + if (allowFallback) { + const fallbacks = await this.derivedFallbackBranches(branch) + for (const fallback of fallbacks) { + if (fallback === branch) continue // already tried + try { + const res = await this.resolveStartPoint(fallback, onProgress, { allowFallback: false }) + return { + ...res, + source: "fallback", + warning: `Branch "${branch}" not found, falling back to "${fallback}"`, + } + } catch { + // continue + } + } + } + + throw new Error(`Could not resolve start point for branch "${branch}"`) + } + + async hasOriginRemote(): Promise { + try { + const remotes = await this.git.getRemotes() + return remotes.some((r) => r.name === "origin") + } catch { + return false + } + } + + async refExistsLocally(ref: string): Promise { + try { + await this.git.raw(["rev-parse", "--verify", `${ref}^{commit}`]) + return true + } catch { + return false + } + } + + async derivedFallbackBranches(requested: string): Promise { + const defaults = [] + try { + defaults.push(await this.defaultBranch()) + } catch {} + return defaults + } + + async repoUsesLfs(): Promise { + // Check .git/lfs/ directory + const gitDir = await this.resolveGitDir() + if (fs.existsSync(path.join(gitDir, "lfs"))) return true + + // Check .gitattributes + try { + const attributes = await fs.promises.readFile(path.join(this.root, ".gitattributes"), "utf-8") + if (attributes.includes("filter=lfs")) return true + } catch {} + + // Check .git/info/attributes + try { + const infoAttributes = await fs.promises.readFile(path.join(gitDir, "info", "attributes"), "utf-8") + if (infoAttributes.includes("filter=lfs")) return true + } catch {} + + return false + } + + async checkLfsAvailable(): Promise { + try { + await execWithShellEnv("git", ["lfs", "version"], { cwd: this.root, timeout: 5000 }) + return true + } catch { + return false + } + } + async currentBranch(): Promise { if (this.ops) { const branch = await this.ops.currentBranch(this.root) @@ -404,28 +566,32 @@ export class WorktreeManager { } async defaultBranch(): Promise { - if (this.ops) { - const remote = await this.ops.resolveRemote(this.root) - const resolved = await this.ops.resolveDefaultBranch(this.root) - if (resolved) { - const prefix = `${remote}/` - return resolved.startsWith(prefix) ? resolved.slice(prefix.length) : resolved - } - } else { - try { - const head = await this.git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]) - const match = head.trim().match(/refs\/remotes\/origin\/(.+)$/) - if (match) return match[1] - } catch {} + // 1. Try symbolic-ref + try { + const head = await this.git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]) + const match = head.trim().match(/refs\/remotes\/origin\/(.+)$/) + if (match) return match[1] + } catch (e) { + this.log(`defaultBranch: symbolic-ref failed: ${e}`) } + // 2. Try current branch (if not detached) try { - const branches = await this.git.branch() - if (branches.all.includes("main")) return "main" - if (branches.all.includes("master")) return "master" - } catch {} + const current = await this.currentBranch() + if (current && current !== "HEAD") return current + } catch (e) { + this.log(`defaultBranch: currentBranch failed: ${e}`) + } - return "main" + // 3. Try first local branch + try { + const branches = await this.git.branchLocal() + if (branches.all.length > 0) return branches.all[0] + } catch (e) { + this.log(`defaultBranch: branchLocal failed: ${e}`) + } + + throw new Error("Could not determine default branch") } // --------------------------------------------------------------------------- @@ -561,13 +727,9 @@ export class WorktreeManager { } } - private exec(cmd: string, args: string[], timeout = 120000): Promise { - return new Promise((resolve, reject) => { - cp.execFile(cmd, args, { cwd: this.root, timeout, encoding: "utf-8" }, (error, stdout) => { - if (error) reject(error) - else resolve(stdout) - }) - }) + private async exec(cmd: string, args: string[], timeout = 120000): Promise { + const { stdout } = await execWithShellEnv(cmd, args, { cwd: this.root, timeout }) + return stdout } private async gitExec(args: string[]): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 9b135fb07b2..02a23db75fc 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -37,6 +37,7 @@ interface StateFile { tabOrder?: Record sessionsCollapsed?: boolean reviewDiffStyle?: "unified" | "split" + defaultBaseBranch?: string } const STATE_FILE = "agent-manager.json" @@ -55,6 +56,7 @@ export class WorktreeStateManager { private tabOrder: Record = {} private collapsed = false private reviewDiffStyle: "unified" | "split" = "unified" + private defaultBase: string | undefined private readonly log: (msg: string) => void private saving: Promise | undefined private pendingSave = false @@ -245,6 +247,19 @@ export class WorktreeStateManager { void this.save() } + // --------------------------------------------------------------------------- + // Default base branch + // --------------------------------------------------------------------------- + + getDefaultBaseBranch(): string | undefined { + return this.defaultBase + } + + setDefaultBaseBranch(value: string | undefined): void { + this.defaultBase = value + void this.save() + } + // --------------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------------- @@ -271,6 +286,7 @@ export class WorktreeStateManager { if (data.reviewDiffStyle === "split") { this.reviewDiffStyle = "split" } + this.defaultBase = data.defaultBaseBranch this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`) } catch (error) { const code = (error as NodeJS.ErrnoException).code @@ -343,6 +359,9 @@ export class WorktreeStateManager { if (this.reviewDiffStyle === "split") { data.reviewDiffStyle = "split" } + if (this.defaultBase) { + data.defaultBaseBranch = this.defaultBase + } try { const dir = path.dirname(this.file) diff --git a/packages/kilo-vscode/src/agent-manager/base-branch.ts b/packages/kilo-vscode/src/agent-manager/base-branch.ts new file mode 100644 index 00000000000..9d67f6f26f7 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/base-branch.ts @@ -0,0 +1,34 @@ +/** + * Pure logic for resolving the effective base branch. + * + * Priority chain: + * 1. Explicit (per-worktree override, from dialog) + * 2. Configured default (persisted in agent-manager.json), if it still exists + * 3. Auto-detect (undefined → WorktreeManager.defaultBranch() at call site) + */ + +/** Trim whitespace, return undefined for empty/blank. */ +export function normalizeBaseBranch(value: string | undefined): string | undefined { + if (!value) return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +/** + * Pick the effective base branch given an explicit override, a configured + * default, and whether that configured branch still exists in the repo. + * + * Returns `{ branch }` with the winner, and optionally `{ stale }` when the + * configured branch no longer exists (so the caller can clear it). + */ +export function chooseBaseBranch(opts: { explicit?: string; configured?: string; configuredExists?: boolean }): { + branch?: string + stale?: string +} { + if (opts.explicit) return { branch: opts.explicit } + if (opts.configured) { + if (opts.configuredExists) return { branch: opts.configured } + return { stale: opts.configured } + } + return {} +} diff --git a/packages/kilo-vscode/src/agent-manager/git-import.ts b/packages/kilo-vscode/src/agent-manager/git-import.ts index 81432295db7..5685f96aa86 100644 --- a/packages/kilo-vscode/src/agent-manager/git-import.ts +++ b/packages/kilo-vscode/src/agent-manager/git-import.ts @@ -4,6 +4,7 @@ export interface BranchListItem { isRemote: boolean isDefault: boolean lastCommitDate?: string + isCheckedOut?: boolean } export interface PRUrlParts { diff --git a/packages/kilo-vscode/src/agent-manager/shell-env.ts b/packages/kilo-vscode/src/agent-manager/shell-env.ts new file mode 100644 index 00000000000..54e8f53b32d --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/shell-env.ts @@ -0,0 +1,173 @@ +/** + * macOS shell environment PATH resolution. + * + * When VS Code is launched from Finder, Spotlight, or the Dock, the extension + * host inherits a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) that excludes + * directories added by package managers (homebrew, nvm, pipx, etc.) and shell + * profiles (.zshrc, .bash_profile). + * + * This module lazily resolves the user's real PATH by spawning a login shell + * and caches the result. The fix is applied on first ENOENT and persisted to + * process.env.PATH so all subsequent child_process calls benefit. + */ + +import { type ExecFileOptionsWithStringEncoding, execFile } from "child_process" +import * as os from "os" +import { promisify } from "util" + +const run = promisify(execFile) + +// Environment variable keys match: letters, digits, underscores, starting with a non-digit. +const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*=/ + +let cached: Record | null = null +let cacheTime = 0 +let wasFallback = false +const TTL = 60_000 +const FALLBACK_TTL = 10_000 + +/** In-flight fix promise so concurrent ENOENT callers wait on the same resolution. */ +let fixing: Promise | null = null +let fixed = false + +/** + * Parse `env` output, handling multiline variable values correctly. + * + * A new entry starts when a line matches `KEY=value` (KEY is a valid + * environment variable name). Lines that don't match are continuations + * of the previous value. + */ +function parseEnvOutput(stdout: string): Record { + const env: Record = {} + let key: string | null = null + let value = "" + + for (const line of stdout.split("\n")) { + const match = ENV_KEY_RE.exec(line) + if (match) { + if (key) env[key] = value + const idx = match[0].length - 1 // position of '=' + key = line.substring(0, idx) + value = line.substring(idx + 1) + } else if (key) { + value += "\n" + line + } + } + if (key) env[key] = value + return env +} + +/** + * Spawn the user's login shell to capture environment variables (primarily PATH). + * Uses `-lc` (login + command) — avoids `-i` (interactive) to skip TTY prompts. + * Results are cached for 1 minute (10 seconds when the fallback was used). + */ +export async function getShellEnvironment(): Promise> { + const now = Date.now() + const ttl = wasFallback ? FALLBACK_TTL : TTL + if (cached && now - cacheTime < ttl) return { ...cached } + + const shell = process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash") + + try { + const { stdout } = await run(shell, ["-lc", "env"], { + timeout: 10_000, + env: { ...process.env, HOME: os.homedir() }, + }) + + const env = parseEnvOutput(stdout) + cached = env + cacheTime = now + wasFallback = false + return { ...env } + } catch (error) { + console.warn(`[shell-env] Failed to get shell environment: ${error}. Falling back to process.env`) + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") env[key] = value + } + cached = env + cacheTime = now + wasFallback = true + return { ...env } + } +} + +/** + * Attempt to resolve the shell environment and patch process.env.PATH. + * Returns true if PATH was actually changed, false otherwise. + */ +async function resolvePath(): Promise { + const original = process.env.PATH + const env = await getShellEnvironment() + + if (env.PATH && env.PATH !== original) { + process.env.PATH = env.PATH + console.log("[shell-env] Patched process.env.PATH for GUI app") + return true + } + // Shell env was a fallback or PATH didn't change — resolution didn't help + return false +} + +/** + * Execute a command, retrying once with shell environment on ENOENT. + * + * On macOS GUI launches, binaries installed by homebrew / nvm / etc. are not + * on the inherited PATH. When the first exec fails with ENOENT (command not + * found), this function resolves the user's login shell environment, patches + * process.env.PATH permanently, and retries the command. + * + * Concurrent callers that hit ENOENT share a single resolution promise so + * none are rejected prematurely. + */ +export async function execWithShellEnv( + cmd: string, + args: string[], + options?: Omit, +): Promise<{ stdout: string; stderr: string }> { + try { + return await run(cmd, args, { ...options, encoding: "utf8" }) + } catch (error) { + if ( + process.platform !== "darwin" || + !(error instanceof Error) || + !("code" in error) || + (error as NodeJS.ErrnoException).code !== "ENOENT" + ) { + throw error + } + + // Already resolved and PATH was actually changed — no point retrying resolution. + // Just retry with the (already-patched) process.env. + if (fixed) { + return await run(cmd, args, { ...options, encoding: "utf8" }) + } + + // If another caller is already resolving, wait for it then retry. + if (fixing) { + await fixing + return await run(cmd, args, { ...options, encoding: "utf8" }) + } + + console.log(`[shell-env] "${cmd}" not found, resolving shell environment`) + + fixing = resolvePath() + try { + fixed = await fixing + } finally { + fixing = null + } + + return await run(cmd, args, { ...options, encoding: "utf8" }) + } +} + +/** Clear the cached environment (for tests). */ +export function clearShellEnvCache(): void { + cached = null + cacheTime = 0 + wasFallback = false + fixing = null + fixed = false +} diff --git a/packages/kilo-vscode/src/path-utils.ts b/packages/kilo-vscode/src/path-utils.ts new file mode 100644 index 00000000000..856a76038a2 --- /dev/null +++ b/packages/kilo-vscode/src/path-utils.ts @@ -0,0 +1,27 @@ +/** + * Check whether a file path is absolute. + * + * Handles both Unix (`/foo/bar`) and Windows (`C:\foo`, `D:/bar`) conventions. + * UNC paths (`\\server\share`) are also treated as absolute. + * + * Returns false for relative paths, bare filenames, empty strings, and + * protocol-prefixed strings like `https://…`. + */ +export function isAbsolutePath(filePath: string): boolean { + if (!filePath) return false + // Unix absolute + if (filePath.charCodeAt(0) === 47 /* / */) return true + // Windows drive letter: C:\ or C:/ + if ( + filePath.length >= 3 && + filePath.charCodeAt(1) === 58 /* : */ && + (filePath.charCodeAt(2) === 92 /* \ */ || filePath.charCodeAt(2) === 47) /* / */ && + ((filePath.charCodeAt(0) >= 65 && filePath.charCodeAt(0) <= 90) /* A-Z */ || + (filePath.charCodeAt(0) >= 97 && filePath.charCodeAt(0) <= 122)) /* a-z */ + ) + return true + // Windows UNC path: \\server\share + if (filePath.length >= 2 && filePath.charCodeAt(0) === 92 /* \ */ && filePath.charCodeAt(1) === 92 /* \ */) + return true + return false +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 3f294e76bb7..7cfffaec205 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -25,6 +25,8 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"), path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), + path.join(ROOT, "webview-ui/agent-manager/BranchSelect.tsx"), + path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), ] const TSX_FILE = TSX_FILES[0] const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") @@ -170,6 +172,7 @@ describe("Agent Manager Provider — onMessage routing", () => { "agentManager.requestRepoInfo", "agentManager.requestState", "agentManager.setTabOrder", + "agentManager.setDefaultBaseBranch", ] for (const msg of expected) { expect(text, `onMessage should handle "${msg}"`).toContain(msg) @@ -385,6 +388,50 @@ describe("KiloProvider — pending session refresh on reconnect", () => { }) }) +// --------------------------------------------------------------------------- +// handleChangeDefaultBaseBranch — listener leak fix +// --------------------------------------------------------------------------- + +describe("Agent Manager — dialog listener cleanup", () => { + const tsx = fs.readFileSync(TSX_FILE, "utf-8") + + /** + * Regression: handleChangeDefaultBaseBranch subscribes to vscode.onMessage + * for branch data. Previously unsub() was only called inside selectBranch() + * and the Escape keydown handler. If the dialog closed via backdrop click or + * external dialog.close(), the listener leaked and stacked on every reopen. + * + * The fix ties unsub() to Solid's onCleanup inside the dialog.show() render + * function so it always disposes regardless of how the dialog closes. + */ + it("handleChangeDefaultBaseBranch uses onCleanup(unsub) inside dialog.show", () => { + const fnStart = tsx.indexOf("const handleChangeDefaultBaseBranch") + expect(fnStart, "handleChangeDefaultBaseBranch must exist").toBeGreaterThan(-1) + + // Grab the function body (enough to cover the dialog.show callback) + const snippet = tsx.slice(fnStart, fnStart + 2000) + + // The dialog.show callback must register onCleanup(unsub) + const showIdx = snippet.indexOf("dialog.show(") + expect(showIdx, "dialog.show() call must exist").toBeGreaterThan(-1) + const afterShow = snippet.slice(showIdx) + expect(afterShow, "onCleanup(unsub) must be inside dialog.show callback").toContain("onCleanup(unsub)") + }) + + it("selectBranch does not manually call unsub (handled by onCleanup)", () => { + const fnStart = tsx.indexOf("const handleChangeDefaultBaseBranch") + const snippet = tsx.slice(fnStart, fnStart + 2000) + + // Find the selectBranch function body + const selStart = snippet.indexOf("const selectBranch") + expect(selStart, "selectBranch must exist").toBeGreaterThan(-1) + const selEnd = snippet.indexOf("}", selStart + 50) + const selBody = snippet.slice(selStart, selEnd + 1) + + expect(selBody, "selectBranch should not call unsub() directly").not.toContain("unsub()") + }) +}) + describe("SetupScriptRunner — task execution model", () => { const runner = fs.readFileSync(SETUP_SCRIPT_RUNNER_FILE, "utf-8") diff --git a/packages/kilo-vscode/tests/unit/base-branch.test.ts b/packages/kilo-vscode/tests/unit/base-branch.test.ts new file mode 100644 index 00000000000..27e17c14a50 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/base-branch.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "bun:test" +import { normalizeBaseBranch, chooseBaseBranch } from "../../src/agent-manager/base-branch" + +describe("normalizeBaseBranch", () => { + it("returns undefined for undefined input", () => { + expect(normalizeBaseBranch(undefined)).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + expect(normalizeBaseBranch("")).toBeUndefined() + }) + + it("returns undefined for whitespace-only string", () => { + expect(normalizeBaseBranch(" ")).toBeUndefined() + expect(normalizeBaseBranch("\t\n")).toBeUndefined() + }) + + it("trims whitespace from valid input", () => { + expect(normalizeBaseBranch(" main ")).toBe("main") + expect(normalizeBaseBranch(" develop\n")).toBe("develop") + }) + + it("returns the value as-is when already trimmed", () => { + expect(normalizeBaseBranch("main")).toBe("main") + expect(normalizeBaseBranch("feature/branch")).toBe("feature/branch") + }) +}) + +describe("chooseBaseBranch", () => { + it("prefers explicit over configured", () => { + const result = chooseBaseBranch({ explicit: "release", configured: "develop", configuredExists: true }) + expect(result).toEqual({ branch: "release" }) + }) + + it("uses configured when it exists and no explicit is given", () => { + const result = chooseBaseBranch({ configured: "develop", configuredExists: true }) + expect(result).toEqual({ branch: "develop" }) + }) + + it("marks configured as stale when it no longer exists", () => { + const result = chooseBaseBranch({ configured: "old-branch", configuredExists: false }) + expect(result).toEqual({ stale: "old-branch" }) + }) + + it("falls back to auto-detect (undefined) when nothing is configured", () => { + const result = chooseBaseBranch({}) + expect(result).toEqual({}) + }) + + it("falls back to auto-detect when configured is undefined", () => { + const result = chooseBaseBranch({ configured: undefined, configuredExists: false }) + expect(result).toEqual({}) + }) + + it("explicit takes priority even when configured is stale", () => { + const result = chooseBaseBranch({ explicit: "main", configured: "deleted", configuredExists: false }) + expect(result).toEqual({ branch: "main" }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/errorUtils.test.ts b/packages/kilo-vscode/tests/unit/errorUtils.test.ts new file mode 100644 index 00000000000..4667a1ba3db --- /dev/null +++ b/packages/kilo-vscode/tests/unit/errorUtils.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "bun:test" +import type { AssistantMessage } from "@kilocode/sdk/v2" +import { + unwrapError, + parseAssistantError, + isUnauthorizedPaidModelError, + isUnauthorizedPromotionLimitError, +} from "../../webview-ui/src/utils/errorUtils" + +type AssistantError = AssistantMessage["error"] + +describe("unwrapError", () => { + it("returns plain string messages unchanged", () => { + expect(unwrapError("something went wrong")).toBe("something went wrong") + }) + + it("extracts message from JSON error object with error.message", () => { + const input = JSON.stringify({ error: { message: "rate limit exceeded" } }) + expect(unwrapError(input)).toBe("rate limit exceeded") + }) + + it("returns original message for malformed JSON", () => { + expect(unwrapError("{not valid json")).toBe("{not valid json") + }) + + it("strips leading 'Error: ' prefix before parsing", () => { + const json = JSON.stringify({ message: "connection refused" }) + expect(unwrapError(`Error: ${json}`)).toBe("connection refused") + }) +}) + +describe("parseAssistantError", () => { + it("returns null for null input", () => { + expect(parseAssistantError(null)).toBeNull() + }) + + it("returns null for undefined input", () => { + expect(parseAssistantError(undefined)).toBeNull() + }) + + it("returns null for non-APIError (e.g. MessageAbortedError)", () => { + const error: AssistantError = { name: "MessageAbortedError", data: { message: "aborted" } } + expect(parseAssistantError(error)).toBeNull() + }) + + it("returns null when APIError has no data", () => { + // Simulate a malformed error where data is missing at runtime + const error = { name: "APIError" } as unknown as AssistantError + expect(parseAssistantError(error)).toBeNull() + }) + + it("extracts statusCode and message from APIError data", () => { + const error: AssistantError = { + name: "APIError", + data: { statusCode: 401, message: "Unauthorized", isRetryable: false }, + } + const result = parseAssistantError(error) + expect(result).toEqual({ statusCode: 401, code: undefined, message: "Unauthorized" }) + }) + + it("extracts code from responseBody JSON with error.code", () => { + const responseBody = JSON.stringify({ error: { code: "PAID_MODEL_AUTH_REQUIRED" } }) + const error: AssistantError = { + name: "APIError", + data: { statusCode: 401, message: "Unauthorized", isRetryable: false, responseBody }, + } + const result = parseAssistantError(error) + expect(result).toEqual({ statusCode: 401, code: "PAID_MODEL_AUTH_REQUIRED", message: "Unauthorized" }) + }) + + it("extracts code from responseBody JSON with top-level code", () => { + const responseBody = JSON.stringify({ code: "PROMOTION_MODEL_LIMIT_REACHED" }) + const error: AssistantError = { + name: "APIError", + data: { statusCode: 429, message: "Too Many Requests", isRetryable: false, responseBody }, + } + const result = parseAssistantError(error) + expect(result).toEqual({ statusCode: 429, code: "PROMOTION_MODEL_LIMIT_REACHED", message: "Too Many Requests" }) + }) + + it("handles invalid responseBody JSON gracefully", () => { + const error: AssistantError = { + name: "APIError", + data: { statusCode: 500, message: "Server Error", isRetryable: false, responseBody: "not json" }, + } + const result = parseAssistantError(error) + expect(result).toEqual({ statusCode: 500, code: undefined, message: "Server Error" }) + }) + + it("handles missing responseBody", () => { + const error: AssistantError = { + name: "APIError", + data: { statusCode: 403, message: "Forbidden", isRetryable: false }, + } + const result = parseAssistantError(error) + expect(result).toEqual({ statusCode: 403, code: undefined, message: "Forbidden" }) + }) +}) + +describe("isUnauthorizedPaidModelError", () => { + it("returns true for 401 + PAID_MODEL_AUTH_REQUIRED", () => { + expect(isUnauthorizedPaidModelError({ statusCode: 401, code: "PAID_MODEL_AUTH_REQUIRED" })).toBe(true) + }) + + it("returns false for 401 + different code", () => { + expect(isUnauthorizedPaidModelError({ statusCode: 401, code: "SOMETHING_ELSE" })).toBe(false) + }) + + it("returns false for null input", () => { + expect(isUnauthorizedPaidModelError(null)).toBe(false) + }) +}) + +describe("isUnauthorizedPromotionLimitError", () => { + it("returns true for 401 + PROMOTION_MODEL_LIMIT_REACHED", () => { + expect(isUnauthorizedPromotionLimitError({ statusCode: 401, code: "PROMOTION_MODEL_LIMIT_REACHED" })).toBe(true) + }) + + it("returns true for 429 + PROMOTION_MODEL_LIMIT_REACHED", () => { + expect(isUnauthorizedPromotionLimitError({ statusCode: 429, code: "PROMOTION_MODEL_LIMIT_REACHED" })).toBe(true) + }) + + it("returns false for null input", () => { + expect(isUnauthorizedPromotionLimitError(null)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/path-utils.test.ts b/packages/kilo-vscode/tests/unit/path-utils.test.ts new file mode 100644 index 00000000000..517e89eded9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/path-utils.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "bun:test" +import { isAbsolutePath } from "../../src/path-utils" + +describe("isAbsolutePath", () => { + // ── Unix absolute paths ────────────────────────────────────────────── + describe("Unix absolute paths", () => { + it("detects root /", () => { + expect(isAbsolutePath("/")).toBe(true) + }) + + it("detects simple path", () => { + expect(isAbsolutePath("/foo/bar")).toBe(true) + }) + + it("detects deep path", () => { + expect(isAbsolutePath("/Users/marius/Documents/project/src/index.ts")).toBe(true) + }) + + it("detects path with spaces", () => { + expect(isAbsolutePath("/home/user/my project/file.ts")).toBe(true) + }) + + it("detects path with dots", () => { + expect(isAbsolutePath("/home/user/../other/./file.ts")).toBe(true) + }) + + it("detects path with special chars", () => { + expect(isAbsolutePath("/tmp/@scope/pkg/index.js")).toBe(true) + }) + }) + + // ── Windows drive-letter paths ─────────────────────────────────────── + describe("Windows drive-letter paths", () => { + it("detects uppercase drive with backslash", () => { + expect(isAbsolutePath("C:\\Users\\marius\\file.ts")).toBe(true) + }) + + it("detects uppercase drive with forward slash", () => { + expect(isAbsolutePath("C:/Users/marius/file.ts")).toBe(true) + }) + + it("detects lowercase drive letter", () => { + expect(isAbsolutePath("c:\\users\\file.ts")).toBe(true) + }) + + it("detects various drive letters", () => { + expect(isAbsolutePath("D:\\data")).toBe(true) + expect(isAbsolutePath("Z:/files")).toBe(true) + expect(isAbsolutePath("e:\\temp")).toBe(true) + }) + + it("detects drive root with backslash", () => { + expect(isAbsolutePath("C:\\")).toBe(true) + }) + + it("detects drive root with forward slash", () => { + expect(isAbsolutePath("C:/")).toBe(true) + }) + + it("detects mixed separators", () => { + expect(isAbsolutePath("C:\\Users/marius\\project/file.ts")).toBe(true) + }) + }) + + // ── Windows UNC paths ──────────────────────────────────────────────── + describe("Windows UNC paths", () => { + it("detects simple UNC path", () => { + expect(isAbsolutePath("\\\\server\\share")).toBe(true) + }) + + it("detects deeply nested UNC path", () => { + expect(isAbsolutePath("\\\\server\\share\\folder\\file.ts")).toBe(true) + }) + + it("detects minimal UNC path", () => { + expect(isAbsolutePath("\\\\ab")).toBe(true) + }) + }) + + // ── Relative paths (should return false) ───────────────────────────── + describe("relative paths", () => { + it("rejects bare filename with extension", () => { + expect(isAbsolutePath("file.ts")).toBe(false) + }) + + it("rejects relative path with directory", () => { + expect(isAbsolutePath("src/index.ts")).toBe(false) + }) + + it("rejects dot-relative path", () => { + expect(isAbsolutePath("./foo/bar.ts")).toBe(false) + }) + + it("rejects parent-relative path", () => { + expect(isAbsolutePath("../foo/bar.ts")).toBe(false) + }) + + it("rejects bare filename without extension", () => { + expect(isAbsolutePath("Makefile")).toBe(false) + }) + + it("rejects package-style path", () => { + expect(isAbsolutePath("@scope/package/index.js")).toBe(false) + }) + }) + + // ── Edge cases ─────────────────────────────────────────────────────── + describe("edge cases", () => { + it("rejects empty string", () => { + expect(isAbsolutePath("")).toBe(false) + }) + + it("rejects URL with protocol", () => { + expect(isAbsolutePath("https://example.com/path")).toBe(false) + expect(isAbsolutePath("http://localhost:3000")).toBe(false) + }) + + it("rejects file:// URL", () => { + expect(isAbsolutePath("file:///foo/bar")).toBe(false) + }) + + it("rejects drive-relative path (C:file without separator)", () => { + // C:file is a Windows drive-relative path, not absolute + expect(isAbsolutePath("C:file.ts")).toBe(false) + }) + + it("rejects bare drive letter with colon only", () => { + expect(isAbsolutePath("C:")).toBe(false) + }) + + it("rejects single backslash (not UNC)", () => { + expect(isAbsolutePath("\\foo")).toBe(false) + }) + + it("rejects number-prefixed colon paths", () => { + expect(isAbsolutePath("1:\\foo")).toBe(false) + }) + + it("rejects symbol-prefixed colon paths", () => { + expect(isAbsolutePath("@:\\foo")).toBe(false) + }) + + it("rejects tilde home path", () => { + expect(isAbsolutePath("~/Documents/file.ts")).toBe(false) + }) + + it("treats /C:/foo as absolute (Unix-style prefix)", () => { + // Starts with / so it's Unix-absolute regardless of Windows-like suffix + expect(isAbsolutePath("/C:/Users/foo")).toBe(true) + }) + + it("rejects whitespace-only strings", () => { + expect(isAbsolutePath(" ")).toBe(false) + expect(isAbsolutePath(" ")).toBe(false) + }) + + it("rejects path starting with dot-dot alone", () => { + expect(isAbsolutePath("..")).toBe(false) + }) + + it("rejects path starting with single dot", () => { + expect(isAbsolutePath(".")).toBe(false) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/shell-env.test.ts b/packages/kilo-vscode/tests/unit/shell-env.test.ts new file mode 100644 index 00000000000..e3e2520866d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/shell-env.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { getShellEnvironment, execWithShellEnv, clearShellEnvCache } from "../../src/agent-manager/shell-env" + +afterEach(() => { + clearShellEnvCache() +}) + +describe("getShellEnvironment", () => { + it("returns an object with PATH", async () => { + const env = await getShellEnvironment() + expect(env).toBeDefined() + expect(typeof env.PATH).toBe("string") + expect(env.PATH!.length).toBeGreaterThan(0) + }) + + it("returns HOME", async () => { + const env = await getShellEnvironment() + expect(typeof env.HOME).toBe("string") + }) + + it("caches results across calls", async () => { + const first = await getShellEnvironment() + const second = await getShellEnvironment() + expect(first.PATH).toBe(second.PATH) + }) + + it("returns a copy (mutations don't corrupt cache)", async () => { + const first = await getShellEnvironment() + first.PATH = "/mutated" + const second = await getShellEnvironment() + expect(second.PATH).not.toBe("/mutated") + }) + + it("handles multiline env values without corrupting PATH", async () => { + // PATH should never contain newlines — verify it parses correctly + // even if other env vars have multiline values (e.g. BASH_FUNC_*) + const env = await getShellEnvironment() + expect(env.PATH).toBeDefined() + expect(env.PATH).not.toContain("\n") + }) +}) + +describe("execWithShellEnv", () => { + it("executes a simple command", async () => { + const { stdout } = await execWithShellEnv("echo", ["hello"]) + expect(stdout.trim()).toBe("hello") + }) + + it("passes cwd option through", async () => { + const { stdout } = await execWithShellEnv("pwd", [], { cwd: "/tmp" }) + // /tmp may resolve to /private/tmp on macOS + expect(stdout.trim()).toMatch(/\/tmp$/) + }) + + it("throws on non-ENOENT errors", async () => { + await expect(execWithShellEnv("ls", ["--nonexistent-flag-that-fails"])).rejects.toThrow() + }) + + it("concurrent calls don't reject prematurely", async () => { + // Both calls should succeed — neither should throw due to a race + const [a, b] = await Promise.all([execWithShellEnv("echo", ["first"]), execWithShellEnv("echo", ["second"])]) + expect(a.stdout.trim()).toBe("first") + expect(b.stdout.trim()).toBe("second") + }) +}) + +describe("clearShellEnvCache", () => { + it("forces fresh resolution on next call", async () => { + const first = await getShellEnvironment() + clearShellEnvCache() + const second = await getShellEnvironment() + // Both should succeed and contain PATH + expect(first.PATH).toBeDefined() + expect(second.PATH).toBeDefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 6cb5dea05e9..af92a94adfe 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -281,7 +281,7 @@ describe("WorktreeManager.createWorktree", () => { expect(result.path).toContain(path.join(".kilocode", "worktrees")) }) - it("records parentBranch as current branch", async () => { + it("records parentBranch as default branch", async () => { const root = await createTempRepo() const git = simpleGit(root) const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim() @@ -465,7 +465,7 @@ describe("WorktreeManager.discoverWorktrees", () => { const discovered = await mgr.discoverWorktrees() expect(discovered.length).toBe(1) - expect(discovered[0].sessionId).toBeUndefined() + expect(discovered[0]?.sessionId).toBeUndefined() }) it("recovers parentBranch from persisted metadata", async () => { @@ -666,3 +666,150 @@ describe("WorktreeManager.checkedOutBranches", () => { expect(checked.has(wt.branch)).toBe(false) }) }) + +// --------------------------------------------------------------------------- +// WorktreeManager -- Start Point Resolution & Helpers +// --------------------------------------------------------------------------- + +describe("WorktreeManager helpers", () => { + it("hasOriginRemote returns false when no remote exists", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + expect(await mgr.hasOriginRemote()).toBe(false) + }) + + it("hasOriginRemote returns true when origin exists", async () => { + const root = await createTempRepo() + const git = simpleGit(root) + await git.addRemote("origin", "https://example.com/repo.git") + const mgr = createManager(root) + expect(await mgr.hasOriginRemote()).toBe(true) + }) + + it("refExistsLocally verifies refs", async () => { + const root = await createTempRepo() + const git = simpleGit(root) + const mgr = createManager(root) + + const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim() + expect(await mgr.refExistsLocally(head)).toBe(true) + expect(await mgr.refExistsLocally("nonexistent")).toBe(false) + expect(await mgr.refExistsLocally("origin/HEAD")).toBe(false) + }) + + it("repoUsesLfs detects .gitattributes", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + expect(await mgr.repoUsesLfs()).toBe(false) + + await fs.writeFile(path.join(root, ".gitattributes"), "*.png filter=lfs diff=lfs merge=lfs -text") + expect(await mgr.repoUsesLfs()).toBe(true) + }) + + it("repoUsesLfs detects .git/lfs directory", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + expect(await mgr.repoUsesLfs()).toBe(false) + + await fs.mkdir(path.join(root, ".git", "lfs"), { recursive: true }) + expect(await mgr.repoUsesLfs()).toBe(true) + }) +}) + +describe("WorktreeManager.resolveStartPoint", () => { + it("falls back to local branch when no remote exists", async () => { + const root = await createTempRepo() + const git = simpleGit(root) + const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim() + const mgr = createManager(root) + + const res = await mgr.resolveStartPoint(head) + expect(res.source).toBe("local-branch") + expect(res.ref).toBe(head) + }) + + it("falls back to default branch when requested does not exist", async () => { + const root = await createTempRepo() + const git = simpleGit(root) + const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim() + const mgr = createManager(root) + + const res = await mgr.resolveStartPoint("nonexistent-feature") + expect(res.source).toBe("fallback") + expect(res.branch).toBe(head) // fallback to default (HEAD) + expect(res.warning).toContain("falling back to") + }) + + it("does not fallback when allowFallback is false", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + + await expect(mgr.resolveStartPoint("nonexistent", undefined, { allowFallback: false })).rejects.toThrow( + "Could not resolve start point", + ) + }) +}) + +describe("WorktreeManager.createWorktree advanced", () => { + it("returns startPointSource in result", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + const res = await mgr.createWorktree({ prompt: "source-test" }) + + expect(res.startPointSource).toBe("local-branch") // no remote in temp repo + }) + + it("does not set upstream tracking on new branch", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + const res = await mgr.createWorktree({ prompt: "no-upstream" }) + + const git = simpleGit(res.path) + // Checking upstream should fail + let error + try { + await git.revparse(["--abbrev-ref", `${res.branch}@{upstream}`]) + } catch (e) { + error = e + } + expect(error).toBeDefined() + }) + + it("fires onProgress callbacks", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + const steps: string[] = [] + + await mgr.createWorktree({ + prompt: "progress-test", + onProgress: (step) => steps.push(step), + }) + + expect(steps).toContain("verifying") + expect(steps).toContain("creating") + }) + + it("creates from an explicitly selected base branch", async () => { + const root = await createTempRepo() + const git = simpleGit(root) + const mgr = createManager(root) + + // Create a new branch 'develop' + await git.checkoutLocalBranch("develop") + await fs.writeFile(path.join(root, "dev.txt"), "dev") + await git.add(".") + await git.commit("dev commit") + + // Create worktree from 'develop' + const res = await mgr.createWorktree({ + prompt: "feature", + baseBranch: "develop", + }) + + expect(res.parentBranch).toBe("develop") + const wtGit = simpleGit(res.path) + const headParams = await wtGit.log(["-1"]) + const devParams = await git.log(["-1"]) + expect(headParams.latest?.hash).toBe(devParams.latest?.hash) + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index 7285adb2c80..a22aabf3970 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -46,7 +46,8 @@ async function disableAnimations(page: Page) { } // Stories to skip from visual regression (add IDs here if needed) -const SKIP = new Set([]) +// Spinner animation captures at an indeterminate frame, causing flaky diffs. +const SKIP = new Set(["agentmanager--worktree-item-busy"]) // Generate one test() per story so Playwright's scheduler can distribute // them freely across workers — no manual sharding needed. diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index efe40e4e2dc..15526854945 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -46,7 +46,8 @@ async function disableAnimations(page: Page) { } // Stories to skip from visual regression (add IDs here if needed) -const SKIP = new Set([]) +// Spinner animation captures at an indeterminate frame, causing flaky diffs. +const SKIP = new Set(["agentmanager--worktree-item-busy"]) // Generate one test() per story so Playwright's scheduler can distribute // them freely across workers — no manual sharding needed. diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/diff-panel-with-diffs-chromium-linux.png index 343447558f1..b285a468bc3 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/diff-panel-with-diffs-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/diff-panel-with-diffs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d8550b95fec011a61707b544af9a18af85dc51f91a3e93d99a4d0e91bbd86b0 -size 20568 +oid sha256:86f1c445d792a9c6353f0899ceb6a777d8e2d8a9490f4d520e79dd8081cc5314 +size 17458 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-with-changes-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-with-changes-chromium-linux.png index 0670e2c5da0..33e7033ee5a 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-with-changes-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a442e795a4bbf42cffde54230853b31e8b62338932d21cda82714726c2626619 -size 14296 +oid sha256:fd1e5b81a0b1de953d43390e694cd5a1045b444c75a5370a574412576dbbe621 +size 13549 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/full-screen-diff-with-changes-chromium-linux.png index 834baace8b3..fab66b07794 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c412052053c3591f14ad30ceb35f34bcbc41b979fc27a07445f43b1f89b55695 -size 23389 +oid sha256:3838b7760984c6c822078de1bdb74067839775d29ab8a0435626d0afff8cbb30 +size 24826 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png new file mode 100644 index 00000000000..4518237d287 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b10df4363232f03ff6357bae92a810b4d27d5590e6ed67e1e2a829aae3e7bffe +size 2087 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-busy-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-busy-chromium-linux.png new file mode 100644 index 00000000000..f983e6fb646 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-busy-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32e7049d7c1bbf5f53d63259074304bf730e9be1139b1e55813494f53bed3bb9 +size 2168 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png new file mode 100644 index 00000000000..1a208d2f6e3 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7888a2095cea698edd1cf6a04c58d0f0dd7da5030d30e705e50d155750295b56 +size 1914 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png new file mode 100644 index 00000000000..ce4aefd2962 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20dd35f3cf3390be1ff516d7531f4f7744d833c2be1f99d9a7d7c847c30c6727 +size 5215 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png new file mode 100644 index 00000000000..126751e3691 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d4fc23f1b7bf2940b3554623d8964f04fc95bedf0752f45a0601bce34b5d585 +size 2554 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png new file mode 100644 index 00000000000..8286777ddd2 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af516a890c5718f9e732faa0b9feb4fdba867a6938c2faaf22dddcebf8960345 +size 2204 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png new file mode 100644 index 00000000000..dc29819cf65 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21dead151263397b963065601adf4a7e4ff82d274552bd5477783cbd6c5193f1 +size 2321 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-200-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-200-chromium-linux.png new file mode 100644 index 00000000000..e84e4e7cf68 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:107e9937d6640579e6b9823e609858be9060e7a6d2855bea1564b1f8585e3c60 +size 3880 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-420-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-420-chromium-linux.png new file mode 100644 index 00000000000..580f5fdf016 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-420-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fc5cb33f9536414b23c63e0bb19259862f6a227603602802e8a41c15e898a47 +size 4110 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/settings-panel-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/settings-panel-chromium-linux.png index ff5fde6e1bc..d2d5413490f 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b782a2ce193e54bd4fdd2ebc1490ac9674325a806ccabd56a7d3ff20c87e423 -size 39285 +oid sha256:ca2d904ad6ea886fc32714fafcae38748a14411e1ee6f436a6bfaf91d0088c59 +size 42199 diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 53299f60f86..d0dd86ed150 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -56,7 +56,6 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { Popover } from "@kilocode/kilo-ui/popover" -import { HoverCard } from "@kilocode/kilo-ui/hover-card" import { VSCodeProvider, useVSCode } from "../src/context/vscode" import { ServerProvider } from "../src/context/server" import { ProviderProvider } from "../src/context/provider" @@ -85,6 +84,8 @@ import { FullScreenDiffView } from "./FullScreenDiffView" import { ApplyDialog } from "./ApplyDialog" import { groupApplyConflicts } from "./apply-conflicts" import type { ReviewComment } from "./review-comments" +import { BranchSelect } from "./BranchSelect" +import { WorktreeItem } from "./WorktreeItem" import "./agent-manager.css" const REVIEW_TAB_ID = "review" @@ -312,6 +313,11 @@ const AgentManagerContent: Component = () => { const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) const [sessionsLoaded, setSessionsLoaded] = createSignal(false) const [isGitRepo, setIsGitRepo] = createSignal(true) + const [repoDetectedBranch, setRepoDetectedBranch] = createSignal() + const [defaultBaseBranch, setDefaultBaseBranch] = createSignal() + + const repoDefaultBranch = () => defaultBaseBranch() ?? repoDetectedBranch() ?? "main" + const hasConfiguredBranch = () => !!defaultBaseBranch() const DEFAULT_SIDEBAR_WIDTH = 260 const MIN_SIDEBAR_WIDTH = 200 @@ -353,6 +359,16 @@ const AgentManagerContent: Component = () => { const PENDING_PREFIX = "pending:" const [activePendingId, setActivePendingId] = createSignal() + // Inline delete confirmation: tracks which worktree is awaiting a second click/press + const [pendingDelete, setPendingDelete] = createSignal(null) + let pendingDeleteTimer: ReturnType | undefined + const cancelPendingDelete = () => { + clearTimeout(pendingDeleteTimer) + setPendingDelete(null) + } + createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) + onCleanup(() => clearTimeout(pendingDeleteTimer)) + // Per-context tab memory: maps sidebar selection key -> last active session/pending ID const [tabMemory, setTabMemory] = createSignal>({}) @@ -731,6 +747,17 @@ const AgentManagerContent: Component = () => { } }) + // Scroll the sidebar to the focused item whenever selection changes (covers keyboard + // navigation, new worktree creation, and any other programmatic selection change). + createEffect(() => { + const id = selection() ?? session.currentSessionID() + if (!id) return + requestAnimationFrame(() => { + const el = document.querySelector(`[data-sidebar-id="${id}"]`) + if (el instanceof HTMLElement) scrollIntoView(el) + }) + }) + // Read-only mode: viewing an unassigned session (not in a worktree or local) const readOnly = createMemo(() => selection() === null && !!session.currentSessionID()) @@ -813,7 +840,7 @@ const AgentManagerContent: Component = () => { const navigate = (direction: "up" | "down") => { const flat: { type: typeof LOCAL | "wt" | "session"; id: string }[] = [ { type: LOCAL, id: LOCAL }, - ...worktrees().map((wt) => ({ type: "wt" as const, id: wt.id })), + ...sortedWorktrees().map((wt) => ({ type: "wt" as const, id: wt.id })), ...unassignedSessions().map((s) => ({ type: "session" as const, id: s.id })), ] if (flat.length === 0) return @@ -984,6 +1011,19 @@ const AgentManagerContent: Component = () => { } window.addEventListener("keydown", preventDefaults, true) + // Delete/Backspace on a selected worktree triggers inline delete confirmation. + // Pressing the key twice in a row (within the 2500ms window) confirms the delete. + const deleteKeyHandler = (e: KeyboardEvent) => { + if (e.key !== "Delete" && e.key !== "Backspace") return + const tag = (e.target as HTMLElement)?.tagName + if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) return + const sel = selection() + if (!sel || sel === LOCAL) return + e.preventDefault() + confirmDeleteWorktree(sel) + } + window.addEventListener("keydown", deleteKeyHandler) + // When the panel regains focus (e.g. returning from terminal), focus the prompt // and clear any stale body styles left by Kobalte modal overlays (dropdowns/dialogs // set pointer-events:none and overflow:hidden on body, but cleanup never runs if @@ -1020,6 +1060,7 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.repoInfo") { const info = msg as AgentManagerRepoInfoMessage setRepoBranch(info.branch) + if (info.defaultBranch) setRepoDetectedBranch(info.defaultBranch) } if (msg.type === "agentManager.worktreeSetup") { @@ -1079,6 +1120,7 @@ const AgentManagerContent: Component = () => { if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") { setReviewDiffStyle(state.reviewDiffStyle) } + if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined) const current = session.currentSessionID() if (current) { const ms = state.sessions.find((s) => s.id === current) @@ -1248,6 +1290,7 @@ const AgentManagerContent: Component = () => { onCleanup(() => { window.removeEventListener("message", handler) window.removeEventListener("keydown", preventDefaults, true) + window.removeEventListener("keydown", deleteKeyHandler) window.removeEventListener("focus", onWindowFocus) unsubCreate() unsubSessions() @@ -1376,6 +1419,102 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.configureSetupScript" }) } + const handleChangeDefaultBaseBranch = () => { + const [search, setSearch] = createSignal("") + const [branches, setBranches] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [highlighted, setHighlighted] = createSignal(-1) + + const unsub = vscode.onMessage((msg) => { + if (msg.type === "agentManager.branches") { + const ev = msg as AgentManagerBranchesMessage + setBranches(ev.branches) + if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch) + setLoading(false) + } + }) + + vscode.postMessage({ type: "agentManager.requestBranches" }) + + const filtered = createMemo(() => { + const s = search().toLowerCase() + if (!s) return branches() + return branches().filter((b) => b.name.toLowerCase().includes(s)) + }) + + const selectBranch = (name: string | undefined) => { + vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name }) + setDefaultBaseBranch(name) + dialog.close() + } + + const handleKeyDown = (e: KeyboardEvent) => { + const items = filtered() + // offset by 1 for auto-detect option (-1 = auto-detect) + const total = items.length + 1 + if (e.key === "ArrowDown") { + e.preventDefault() + e.stopPropagation() + setHighlighted((prev) => Math.min(prev + 1, total - 2)) + } else if (e.key === "ArrowUp") { + e.preventDefault() + e.stopPropagation() + setHighlighted((prev) => Math.max(prev - 1, -1)) + } else if (e.key === "Enter") { + e.preventDefault() + e.stopPropagation() + const idx = highlighted() + if (idx === -1) { + selectBranch(undefined) + } else { + const branch = items[idx] + if (branch) selectBranch(branch.name) + } + } else if (e.key === "Escape") { + e.preventDefault() + e.stopPropagation() + dialog.close() + } + } + + dialog.show(() => { + onCleanup(unsub) + return ( + +
+ { + setSearch(v) + setHighlighted(-1) + }} + onSelect={(b) => selectBranch(b.name)} + onSearchKeyDown={handleKeyDown} + selected={defaultBaseBranch()} + highlighted={highlighted()} + onHighlight={setHighlighted} + searchPlaceholder={t("agentManager.dialog.searchBranches")} + emptyLabel={t("agentManager.import.noMatchingBranches")} + loadingLabel={t("agentManager.import.loadingBranches")} + defaultLabel={t("agentManager.dialog.branchBadge.default")} + remoteLabel={t("agentManager.dialog.branchBadge.remote")} + defaultName={defaultBaseBranch()} + autoOption={{ + label: t("agentManager.worktree.defaultBaseBranchAuto"), + hint: repoDetectedBranch(), + active: !hasConfiguredBranch(), + highlighted: highlighted() === -1, + onSelect: () => selectBranch(undefined), + }} + /> +
+
+ ) + }) + } + const handleShowKeyboardShortcuts = () => { const categories = buildShortcutCategories(kb(), t) dialog.show(() => ( @@ -1413,13 +1552,16 @@ const AgentManagerContent: Component = () => { // Advanced worktree dialog — opens a full dialog with prompt, versions, model, mode const showAdvancedWorktreeDialog = () => { - dialog.show(() => dialog.close()} />) + dialog.show(() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />) } const confirmDeleteWorktree = (worktreeId: string) => { const wt = worktrees().find((w) => w.id === worktreeId) if (!wt) return - const doDelete = () => { + + // Second press/click: execute the delete + if (pendingDelete() === worktreeId) { + cancelPendingDelete() setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "deleting" as const }]])) vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id }) if (selection() === wt.id) { @@ -1430,36 +1572,13 @@ const AgentManagerContent: Component = () => { if (next === LOCAL) selectLocal() else selectWorktree(next) } - dialog.close() + return } - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - doDelete() - } - } - dialog.show(() => ( - -
-
- - - {t("agentManager.dialog.deleteWorktree.messagePre")} - {wt.branch} - {t("agentManager.dialog.deleteWorktree.messagePost")} - -
-
- - -
-
-
- )) + + // First press/click: enter pending-delete state + clearTimeout(pendingDeleteTimer) + setPendingDelete(worktreeId) + pendingDeleteTimer = setTimeout(() => setPendingDelete(null), 2500) } const confirmRemoveStaleWorktree = (worktreeId: string) => { @@ -1833,6 +1952,12 @@ const AgentManagerContent: Component = () => { {t("agentManager.worktree.setupScript")} + + + + {t("agentManager.worktree.defaultBaseBranch")}: {repoDefaultBranch()} + + @@ -1859,8 +1984,6 @@ const AgentManagerContent: Component = () => { {(() => { - const [hoveredWt, setHoveredWt] = createSignal(null) - const [overClose, setOverClose] = createSignal(false) const [renamingWt, setRenamingWt] = createSignal(null) const [renameValue, setRenameValue] = createSignal("") @@ -1890,14 +2013,6 @@ const AgentManagerContent: Component = () => { return ( {(wt, idx) => { - const grouped = () => isGrouped(wt) - const start = () => isGroupStart(wt, idx()) - const end = () => isGroupEnd(wt, idx()) - const busy = () => busyWorktrees().has(wt.id) - const groupSize = () => { - if (!wt.groupId) return 0 - return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length - } const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) const navHint = () => { const flat = [ @@ -1908,269 +2023,43 @@ const AgentManagerContent: Component = () => { const active = selection() ?? session.currentSessionID() ?? "" return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "") } + const groupSize = () => { + if (!wt.groupId) return 0 + return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length + } return ( - <> - -
- - - {t("agentManager.worktree.versions", { count: groupSize() })} - -
-
- setHoveredWt(open ? wt.id : null)} - trigger={ -
selectWorktree(wt.id)} - > - } - > - - - - - - - - - - { - e.stopPropagation() - startRename(wt.id, worktreeLabel(wt)) - }} - title={t("agentManager.worktree.doubleClickRename")} - > - {worktreeLabel(wt)} - - } - > - setRenameValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - commitRename(wt.id) - } - if (e.key === "Escape") { - e.preventDefault() - cancelRename() - } - }} - onBlur={() => commitRename(wt.id)} - onClick={(e) => e.stopPropagation()} - ref={(el) => - requestAnimationFrame(() => { - el.focus() - el.select() - }) - } - /> - - {(() => { - const num = idx() + 2 - const stats = () => worktreeStats()[wt.id] - return ( - <> - - - {isMac ? "⌘" : "Ctrl+"} - {num} - - - 0 || - stats()!.additions > 0 || - stats()!.deletions > 0 || - stats()!.ahead > 0 || - stats()!.behind > 0) - } - > -
- 0}> - {stats()!.files}f - - 0 || stats()!.deletions > 0}> - - 0}> - +{stats()!.additions} - - 0}> - −{stats()!.deletions} - - - - 0}> - ↑{stats()!.ahead} - - 0}> - ↓{stats()!.behind} - -
-
- - ) - })()} - -
setOverClose(true)} - onMouseLeave={() => setOverClose(false)} - > - - handleDeleteWorktree(wt.id, e)} - /> - -
-
-
+ { + if (pendingDelete() === wt.id) { + confirmDeleteWorktree(wt.id) + return } - > -
-
-
-
{t("agentManager.hoverCard.branch")}
-
{wt.branch}
-
{formatRelativeDate(wt.createdAt)}
-
- - {navHint()} - -
- -
-
- {t("agentManager.hoverCard.base")} - {wt.parentBranch} -
- -
-
- {t("agentManager.hoverCard.sessions")} - {sessions().length} -
- -
-
- {t("agentManager.worktree.stale")} - - - {t("agentManager.worktree.stale")} - -
-
{t("agentManager.worktree.staleTooltip")}
-
- -
- - {(() => { - const hoverStats = () => worktreeStats()[wt.id] - return ( - 0 || - hoverStats()!.additions > 0 || - hoverStats()!.deletions > 0 || - hoverStats()!.ahead > 0 || - hoverStats()!.behind > 0) - } - > -
- 0}> -
- - {t("agentManager.hoverCard.files")} - - {hoverStats()!.files} -
-
- 0 || hoverStats()!.deletions > 0}> -
- - {t("agentManager.hoverCard.changes")} - - - 0}> - +{hoverStats()!.additions} - - 0}> - −{hoverStats()!.deletions} - - -
-
- 0 || hoverStats()!.behind > 0}> -
- - {t("agentManager.hoverCard.commits")} - - - 0}> - ↑{hoverStats()!.ahead} - - 0}> - ↓{hoverStats()!.behind} - - -
-
- - ) - })()} -
-
- - {t("agentManager.worktree.doubleClickRename")} -
-
- - + selectWorktree(wt.id) + }} + onDelete={(e) => handleDeleteWorktree(wt.id, e)} + onStartRename={(current) => startRename(wt.id, current)} + onRenameInput={(v) => setRenameValue(v)} + onCommitRename={() => commitRename(wt.id)} + onCancelRename={cancelRename} + onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)} + /> ) }} @@ -2590,6 +2479,7 @@ const AgentManagerContent: Component = () => { onOpenFile={(file) => { const id = session.currentSessionID() if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file }) + else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file }) }} />
@@ -2611,6 +2501,7 @@ const AgentManagerContent: Component = () => { onOpenFile={(file) => { const id = session.currentSessionID() if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file }) + else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file }) }} onClose={closeReviewTab} /> @@ -2653,7 +2544,7 @@ function sanitizeBranchName(name: string): string { .join("/") } -const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { +const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBranch?: string }> = (props) => { const { t } = useLanguage() const vscode = useVSCode() const session = useSession() @@ -2663,7 +2554,7 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { // --- Shared branch data (used by both New tab's base branch selector and Import tab) --- const [branches, setBranches] = createSignal([]) const [branchesLoading, setBranchesLoading] = createSignal(false) - const [defaultBranch, setDefaultBranch] = createSignal("main") + const [defaultBranch, setDefaultBranch] = createSignal(props.defaultBaseBranch ?? "main") const [branchSearch, setBranchSearch] = createSignal("") // --- New tab state --- @@ -2679,6 +2570,7 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { const [branchName, setBranchName] = createSignal("") const [baseBranch, setBaseBranch] = createSignal(null) const [baseBranchOpen, setBaseBranchOpen] = createSignal(false) + const [compareOpen, setCompareOpen] = createSignal(false) const [highlightedIndex, setHighlightedIndex] = createSignal(0) const imageAttach = useImageAttachments() @@ -2769,7 +2661,7 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { if (msg.type === "agentManager.branches") { const ev = msg as AgentManagerBranchesMessage setBranches(ev.branches) - setDefaultBranch(ev.defaultBranch) + if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch) setBranchesLoading(false) } if (msg.type === "agentManager.importResult") { @@ -2826,139 +2718,142 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { {/* New tab */}
- setName(e.currentTarget.value)} - /> - {/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */} -
- 0}> -
- - {(img) => ( -
- {img.filename} - -
- )} -
-
-
-
-
-