Merge branch 'main' into feat/cloud-sessions-list

This commit is contained in:
Igor Šćekić
2026-02-24 12:43:18 +01:00
11 changed files with 1951 additions and 328 deletions
@@ -32,6 +32,7 @@ export class AgentManagerProvider implements vscode.Disposable {
private setupScript: SetupScriptService | undefined
private terminalManager: SessionTerminalManager
private stateReady: Promise<void> | undefined
private importing = false
constructor(
private readonly extensionUri: vscode.Uri,
@@ -172,19 +173,30 @@ export class AgentManagerProvider implements vscode.Disposable {
if (type === "agentManager.requestState") {
void this.stateReady
?.then(() => {
// When the workspace is not a git repo (or has no folder open),
// this.state is never created. pushState() silently returns in that
// case, so re-send the empty/non-git state explicitly.
if (!this.state) {
this.pushEmptyState()
return
}
this.pushState()
// Refresh sessions after pushState so the webview's sessionsLoaded
// handler is guaranteed to be registered (requestState fires from
// onMount). Without this, the initial refreshSessions() in
// initializeState() can race ahead of webview mount, causing
// sessionsLoaded to never flip to true.
if (this.state && this.state.getSessions().length > 0) {
if (this.state.getSessions().length > 0) {
this.provider?.refreshSessions()
}
})
.catch((err) => {
this.log("initializeState failed, pushing partial state:", err)
this.pushState()
if (!this.state) {
this.pushEmptyState()
} else {
this.pushState()
}
})
return null
}
@@ -201,6 +213,35 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
if (type === "agentManager.requestBranches") {
void this.onRequestBranches()
return null
}
if (type === "agentManager.requestExternalWorktrees") {
void this.onRequestExternalWorktrees()
return null
}
if (type === "agentManager.importFromBranch" && typeof msg.branch === "string") {
void this.onImportFromBranch(msg.branch as string)
return null
}
if (type === "agentManager.importFromPR" && typeof msg.url === "string") {
void this.onImportFromPR(msg.url as string)
return null
}
if (
type === "agentManager.importExternalWorktree" &&
typeof msg.path === "string" &&
typeof msg.branch === "string"
) {
void this.onImportExternalWorktree(msg.path as string, msg.branch as string)
return null
}
if (type === "agentManager.importAllExternalWorktrees") {
void this.onImportAllExternalWorktrees()
return null
}
// When switching sessions, show existing terminal if one is open
if (type === "loadMessages" && typeof msg.sessionID === "string") {
this.terminalManager.showExisting(msg.sessionID)
@@ -523,21 +564,6 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
// ---------------------------------------------------------------------------
// Branch discovery
// ---------------------------------------------------------------------------
private async onRequestBranches(): Promise<void> {
const manager = this.getWorktreeManager()
if (!manager) return
try {
const data = await manager.listBranches()
this.postToWebview({ type: "agentManager.branches", branches: data.branches, defaultBranch: data.defaultBranch })
} catch (error) {
this.log(`Failed to list branches: ${error}`)
}
}
// ---------------------------------------------------------------------------
// Multi-version worktree creation
// ---------------------------------------------------------------------------
@@ -688,6 +714,322 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
private async onRequestBranches(): Promise<void> {
const manager = this.getWorktreeManager()
if (!manager) {
this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
return
}
try {
const result = await manager.listBranches()
const checkedOut = await manager.checkedOutBranches()
const filtered = result.branches.filter((b) => !checkedOut.has(b.name))
this.postToWebview({
type: "agentManager.branches",
branches: filtered,
defaultBranch: result.defaultBranch,
})
} catch (error) {
this.log(`Failed to list branches: ${error}`)
this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
}
}
private async onRequestExternalWorktrees(): Promise<void> {
const manager = this.getWorktreeManager()
const state = this.getStateManager()
if (!manager || !state) {
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] })
return
}
try {
const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path))
const worktrees = await manager.listExternalWorktrees(managedPaths)
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees })
} catch (error) {
this.log(`Failed to list external worktrees: ${error}`)
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] })
}
}
private async onImportFromBranch(branch: string): Promise<void> {
const manager = this.getWorktreeManager()
const state = this.getStateManager()
if (!manager || !state) {
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
return
}
if (this.importing) {
this.postToWebview({
type: "agentManager.importResult",
success: false,
message: "Another import is already in progress",
})
return
}
this.importing = true
try {
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Creating worktree from branch...",
})
const result = await manager.createWorktree({ existingBranch: branch })
const worktree = state.addWorktree({
branch: result.branch,
path: result.path,
parentBranch: result.parentBranch,
})
this.pushState()
try {
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Running setup script...",
branch: result.branch,
worktreeId: worktree.id,
})
await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id)
const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id)
if (!session) throw new Error("Failed to create session")
state.addSession(session.id, worktree.id)
this.registerWorktreeSession(session.id, result.path)
this.notifyWorktreeReady(session.id, result, worktree.id)
this.postToWebview({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` })
this.log(`Imported branch ${branch} as worktree ${worktree.id}`)
} catch (inner) {
state.removeWorktree(worktree.id)
await manager.removeWorktree(result.path)
this.pushState()
throw inner
}
} catch (error) {
const raw = error instanceof Error ? error.message : String(error)
const msg =
raw.includes("already used by worktree") || raw.includes("already checked out")
? `Branch "${branch}" is already checked out in another worktree`
: raw
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg })
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
} finally {
this.importing = false
}
}
private async onImportFromPR(url: string): Promise<void> {
const manager = this.getWorktreeManager()
const state = this.getStateManager()
if (!manager || !state) {
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
return
}
if (this.importing) {
this.postToWebview({
type: "agentManager.importResult",
success: false,
message: "Another import is already in progress",
})
return
}
this.importing = true
try {
this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." })
const result = await manager.createFromPR(url)
const worktree = state.addWorktree({
branch: result.branch,
path: result.path,
parentBranch: result.parentBranch,
})
this.pushState()
try {
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Setting up workspace...",
branch: result.branch,
worktreeId: worktree.id,
})
await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id)
const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id)
if (!session) throw new Error("Failed to create session")
state.addSession(session.id, worktree.id)
this.registerWorktreeSession(session.id, result.path)
this.notifyWorktreeReady(session.id, result, worktree.id)
this.postToWebview({
type: "agentManager.importResult",
success: true,
message: `Opened PR branch ${result.branch}`,
})
this.log(`Imported PR ${url} as worktree ${worktree.id}`)
} catch (inner) {
state.removeWorktree(worktree.id)
await manager.removeWorktree(result.path)
this.pushState()
throw inner
}
} catch (error) {
const raw = error instanceof Error ? error.message : String(error)
const msg =
raw.includes("already used by worktree") || raw.includes("already checked out")
? "This PR's branch is already checked out in another worktree"
: raw
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg })
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
} finally {
this.importing = false
}
}
private async onImportExternalWorktree(wtPath: string, branch: string): Promise<void> {
const state = this.getStateManager()
const manager = this.getWorktreeManager()
if (!state || !manager) {
this.postToWebview({ type: "agentManager.importResult", success: false, message: "State not initialized" })
return
}
if (this.importing) {
this.postToWebview({
type: "agentManager.importResult",
success: false,
message: "Another import is already in progress",
})
return
}
this.importing = true
let worktree: ReturnType<typeof state.addWorktree> | undefined
try {
const externals = await manager.listExternalWorktrees(new Set(state.getWorktrees().map((wt) => wt.path)))
if (!externals.some((e) => e.path === wtPath)) {
this.postToWebview({
type: "agentManager.importResult",
success: false,
message: "Path is not a valid worktree for this repository",
})
return
}
const parent = await manager.defaultBranch()
worktree = state.addWorktree({ branch, path: wtPath, parentBranch: parent })
this.pushState()
const session = await this.createSessionInWorktree(wtPath, branch, worktree.id)
if (!session) {
state.removeWorktree(worktree.id)
this.pushState()
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Failed to create session" })
return
}
state.addSession(session.id, worktree.id)
this.registerWorktreeSession(session.id, wtPath)
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "ready",
message: "Worktree imported",
sessionId: session.id,
branch,
worktreeId: worktree.id,
})
this.postToWebview({
type: "agentManager.sessionMeta",
sessionId: session.id,
mode: "worktree",
branch,
path: wtPath,
parentBranch: parent,
})
this.postToWebview({ type: "agentManager.importResult", success: true, message: `Imported ${branch}` })
this.log(`Imported external worktree ${wtPath} (${branch})`)
} catch (error) {
if (worktree) {
state.removeWorktree(worktree.id)
this.pushState()
}
const msg = error instanceof Error ? error.message : String(error)
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
} finally {
this.importing = false
}
}
private async onImportAllExternalWorktrees(): Promise<void> {
if (this.importing) {
this.postToWebview({
type: "agentManager.importResult",
success: false,
message: "Another import is already in progress",
})
return
}
const manager = this.getWorktreeManager()
const state = this.getStateManager()
if (!manager || !state) {
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
return
}
this.importing = true
try {
const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path))
const externals = await manager.listExternalWorktrees(managedPaths)
if (externals.length === 0) {
this.postToWebview({
type: "agentManager.importResult",
success: true,
message: "No external worktrees to import",
})
return
}
let imported = 0
const parent = await manager.defaultBranch()
for (const ext of externals) {
try {
const worktree = state.addWorktree({ branch: ext.branch, path: ext.path, parentBranch: parent })
const session = await this.createSessionInWorktree(ext.path, ext.branch, worktree.id)
if (session) {
state.addSession(session.id, worktree.id)
this.registerWorktreeSession(session.id, ext.path)
imported++
} else {
state.removeWorktree(worktree.id)
}
} catch (error) {
this.log(`Failed to import external worktree ${ext.path}: ${error}`)
}
}
this.pushState()
this.postToWebview({
type: "agentManager.importResult",
success: true,
message: `Imported ${imported} workspace${imported !== 1 ? "s" : ""}`,
})
this.log(`Imported ${imported}/${externals.length} external worktrees`)
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
} finally {
this.importing = false
}
}
// ---------------------------------------------------------------------------
// Keybindings
// ---------------------------------------------------------------------------
@@ -8,9 +8,23 @@
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 } from "./branch-name"
import {
parsePRUrl,
localBranchName,
parseForEachRefOutput,
buildBranchList,
parseWorktreeList,
checkedOutBranchesFromWorktreeList,
classifyPRError,
validateGitRef,
type PRInfo,
type BranchListItem,
} from "./git-import"
export type { BranchListItem }
export { generateBranchName }
export interface WorktreeInfo {
@@ -27,12 +41,9 @@ export interface CreateWorktreeResult {
parentBranch: string
}
export interface BranchInfo {
name: string
isLocal: boolean
isRemote: boolean
lastCommitDate: number
isDefault: boolean
export interface ExternalWorktreeItem {
path: string
branch: string
}
const KILOCODE_DIR = ".kilocode"
@@ -87,7 +98,6 @@ export class WorktreeManager {
if (!exists) throw new Error(`Branch "${branch}" does not exist`)
}
// Sanitize directory name — replace slashes with dashes for filesystem safety
const dirName = branch.replace(/\//g, "-")
let worktreePath = path.join(this.dir, dirName)
@@ -215,73 +225,6 @@ export class WorktreeManager {
return undefined
}
// ---------------------------------------------------------------------------
// Branch & worktree discovery
// ---------------------------------------------------------------------------
async listBranches(): Promise<{ branches: BranchInfo[]; defaultBranch: string }> {
const defBranch = await this.defaultBranch()
// Get local branches with commit dates
const localRaw = await this.git
.raw(["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)\t%(committerdate:unix)", "refs/heads/"])
.then((out) => out.trim())
.catch(() => "")
// Get remote branches with commit dates
const remoteRaw = await this.git
.raw([
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname:short)\t%(committerdate:unix)",
"refs/remotes/origin/",
])
.then((out) => out.trim())
.catch(() => "")
const map = new Map<string, BranchInfo>()
for (const line of localRaw.split("\n").filter(Boolean)) {
const [name, dateStr] = line.split("\t")
if (!name) continue
map.set(name, {
name,
isLocal: true,
isRemote: false,
lastCommitDate: parseInt(dateStr || "0", 10),
isDefault: name === defBranch,
})
}
for (const line of remoteRaw.split("\n").filter(Boolean)) {
const [ref, dateStr] = line.split("\t")
if (!ref) continue
const name = ref.replace(/^origin\//, "")
if (name === "HEAD") continue
const existing = map.get(name)
if (existing) {
existing.isRemote = true
} else {
map.set(name, {
name,
isLocal: false,
isRemote: true,
lastCommitDate: parseInt(dateStr || "0", 10),
isDefault: name === defBranch,
})
}
}
// Sort: default first, then by lastCommitDate descending
const branches = [...map.values()].sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1
if (!a.isDefault && b.isDefault) return 1
return b.lastCommitDate - a.lastCommitDate
})
return { branches, defaultBranch: defBranch }
}
// ---------------------------------------------------------------------------
// Git exclude management
// ---------------------------------------------------------------------------
@@ -409,4 +352,151 @@ export class WorktreeManager {
return "main"
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
async listBranches(): Promise<{ branches: BranchListItem[]; defaultBranch: string }> {
const defBranch = await this.defaultBranch()
const raw = await this.git.raw([
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname)\t%(committerdate:iso-strict)",
"refs/heads/",
"refs/remotes/origin/",
])
const { locals, remotes, dates } = parseForEachRefOutput(raw)
return { branches: buildBranchList(locals, remotes, dates, defBranch), defaultBranch: defBranch }
}
async checkedOutBranches(): Promise<Set<string>> {
try {
const raw = await this.git.raw(["worktree", "list", "--porcelain"])
return checkedOutBranchesFromWorktreeList(raw)
} catch (error) {
this.log(`Failed to list worktree branches: ${error}`)
const result = new Set<string>()
try {
result.add(await this.currentBranch())
} catch (inner) {
this.log(`Failed to get current branch: ${inner}`)
}
return result
}
}
async listExternalWorktrees(managedPaths: Set<string>): Promise<ExternalWorktreeItem[]> {
try {
const raw = await this.git.raw(["worktree", "list", "--porcelain"])
return parseWorktreeList(raw)
.filter((e) => !e.bare && e.path !== this.root && !managedPaths.has(e.path))
.map((e) => ({ path: e.path, branch: e.branch }))
} catch (error) {
this.log(`Failed to list external worktrees: ${error}`)
return []
}
}
async createFromPR(url: string): Promise<CreateWorktreeResult> {
const parsed = parsePRUrl(url)
if (!parsed) throw new Error("Invalid PR URL. Expected: https://github.com/owner/repo/pull/123")
const info = await this.fetchPRInfo(parsed)
const branch = localBranchName(info)
const isFork = info.isCrossRepository
const forkOwner = info.headRepositoryOwner?.login?.toLowerCase()
const checkedOut = await this.checkedOutBranches()
if (checkedOut.has(branch) || checkedOut.has(info.headRefName)) {
throw new Error("This PR's branch is already checked out in another worktree")
}
await this.fetchPRBranch(info, parsed, isFork, forkOwner)
if (isFork && forkOwner) {
if (await this.branchExists(branch)) {
await this.git.raw(["branch", "-D", branch])
}
await this.git.raw(["branch", branch, `${forkOwner}/${info.headRefName}`])
}
return this.createWorktree({ existingBranch: branch })
}
private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise<PRInfo> {
try {
const json = await this.exec(
"gh",
[
"pr",
"view",
String(parsed.number),
"--repo",
`${parsed.owner}/${parsed.repo}`,
"--json",
"headRefName,headRepositoryOwner,isCrossRepository,title",
],
30000,
)
return JSON.parse(json) as PRInfo
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
const kind = classifyPRError(msg)
if (kind === "not_found") throw new Error(`PR #${parsed.number} not found in ${parsed.owner}/${parsed.repo}`)
if (kind === "gh_missing")
throw new Error("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")
if (kind === "gh_auth") throw new Error("Not authenticated with GitHub CLI. Run 'gh auth login' first.")
throw new Error(`Failed to fetch PR info: ${msg}`)
}
}
private async fetchPRBranch(
info: PRInfo,
parsed: { owner: string; repo: string; number: number },
isFork: boolean,
forkOwner: string | undefined,
): Promise<void> {
if (isFork && forkOwner) {
validateGitRef(forkOwner, "fork owner")
validateGitRef(info.headRefName, "branch name")
const remotes = await this.git.getRemotes()
if (!remotes.some((r) => r.name === forkOwner)) {
await this.git.addRemote(forkOwner, `https://github.com/${forkOwner}/${parsed.repo}.git`)
}
await this.gitExec(["fetch", forkOwner, info.headRefName])
} else {
validateGitRef(info.headRefName, "branch name")
const ok = await this.gitTry(["fetch", "origin", info.headRefName])
if (!ok) {
await this.gitExec([
"fetch",
"origin",
`+refs/pull/${parsed.number}/head:refs/remotes/origin/${info.headRefName}`,
])
}
}
}
private exec(cmd: string, args: string[], timeout = 120000): Promise<string> {
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 gitExec(args: string[]): Promise<void> {
await this.exec("git", args)
}
private async gitTry(args: string[]): Promise<boolean> {
try {
await this.gitExec(args)
return true
} catch {
return false
}
}
}
@@ -320,8 +320,17 @@ export class WorktreeStateManager {
data.sessionsCollapsed = true
}
const dir = path.dirname(this.file)
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.writeFile(this.file, JSON.stringify(data, null, 2), "utf-8")
try {
const dir = path.dirname(this.file)
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.writeFile(this.file, JSON.stringify(data, null, 2), "utf-8")
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "ENOENT") {
this.log("State directory was removed, skipping save")
return
}
throw error
}
}
}
@@ -0,0 +1,141 @@
export interface BranchListItem {
name: string
isLocal: boolean
isRemote: boolean
isDefault: boolean
lastCommitDate?: string
}
export interface PRUrlParts {
owner: string
repo: string
number: number
}
export interface PRInfo {
headRefName: string
headRepositoryOwner?: { login: string }
isCrossRepository: boolean
title: string
}
export interface WorktreeEntry {
path: string
branch: string
bare: boolean
detached: boolean
}
export type PRErrorKind = "not_found" | "gh_missing" | "gh_auth" | "unknown"
export function parsePRUrl(url: string): PRUrlParts | null {
let normalized = url.trim()
if (!normalized.startsWith("http")) normalized = `https://${normalized}`
normalized = normalized.replace(/\/+$/, "")
const match = normalized.match(/\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/)
if (!match) return null
return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) }
}
export function localBranchName(info: PRInfo): string {
if (info.isCrossRepository) {
const owner = info.headRepositoryOwner?.login?.toLowerCase()
if (owner) return `${owner}/${info.headRefName}`
}
return info.headRefName
}
export function parseForEachRefOutput(raw: string): {
locals: Set<string>
remotes: Set<string>
dates: Map<string, string>
} {
const locals = new Set<string>()
const remotes = new Set<string>()
const dates = new Map<string, string>()
for (const line of raw.split("\n")) {
if (!line) continue
const [ref, date] = line.split("\t")
if (ref.includes("HEAD")) continue
if (ref.startsWith("refs/heads/")) {
const name = ref.slice(11)
locals.add(name)
if (date && !dates.has(name)) dates.set(name, date)
} else if (ref.startsWith("refs/remotes/origin/")) {
const name = ref.slice(20)
remotes.add(name)
if (date && !dates.has(name)) dates.set(name, date)
}
}
return { locals, remotes, dates }
}
export function buildBranchList(
locals: Set<string>,
remotes: Set<string>,
dates: Map<string, string>,
defaultBranch: string,
): BranchListItem[] {
const all = new Set([...locals, ...remotes])
const branches: BranchListItem[] = [...all].map((name) => ({
name,
isLocal: locals.has(name),
isRemote: remotes.has(name),
isDefault: name === defaultBranch,
lastCommitDate: dates.get(name),
}))
branches.sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1
if (!a.isDefault && b.isDefault) return 1
if (a.lastCommitDate && b.lastCommitDate) return b.lastCommitDate.localeCompare(a.lastCommitDate)
return 0
})
return branches
}
export function parseWorktreeList(raw: string): WorktreeEntry[] {
const entries: WorktreeEntry[] = []
for (const block of raw.split("\n\n")) {
if (!block.trim()) continue
const lines = block.split("\n")
const wtPath = lines.find((l) => l.startsWith("worktree "))?.slice(9)
if (!wtPath) continue
const branchLine = lines.find((l) => l.startsWith("branch "))
const bare = lines.some((l) => l === "bare")
const detached = lines.some((l) => l === "detached")
const branch = branchLine ? branchLine.slice(7).replace("refs/heads/", "") : detached ? "(detached)" : "unknown"
entries.push({ path: wtPath, branch, bare, detached })
}
return entries
}
export function checkedOutBranchesFromWorktreeList(raw: string): Set<string> {
const result = new Set<string>()
for (const entry of parseWorktreeList(raw)) {
if (!entry.bare && !entry.detached) result.add(entry.branch)
}
return result
}
const SAFE_GIT_REF = /^[a-zA-Z0-9._\-/]+$/
export function validateGitRef(value: string, label: string): void {
if (!value || !SAFE_GIT_REF.test(value) || value.startsWith("-") || value.includes("..")) {
throw new Error(`Unsafe ${label}: "${value}"`)
}
}
export function classifyPRError(msg: string): PRErrorKind {
if (msg.includes("command not found") || msg.includes("ENOENT") || msg.includes("is not recognized"))
return "gh_missing"
if (msg.includes("not logged") || msg.includes("auth login")) return "gh_auth"
if (msg.includes("not found") || msg.includes("Could not resolve")) return "not_found"
return "unknown"
}
@@ -256,4 +256,55 @@ describe("Agent Manager Provider — onMessage routing", () => {
// Must also send sessionMeta so the webview knows the branch/path
expect(text).toContain("agentManager.sessionMeta")
})
// -- agentManager.requestState in non-git workspace -------------------------
/**
* Regression: when the workspace is not a git repo, this.state is undefined.
* pushState() silently returns in that case, so requestState must explicitly
* call pushEmptyState() instead — otherwise the webview stays stuck on
* loading skeletons forever.
*/
it("requestState handler calls pushEmptyState when this.state is falsy", () => {
const text = body("onMessage")
// Extract the requestState branch
const start = text.indexOf('"agentManager.requestState"')
expect(start, "requestState branch must exist").toBeGreaterThan(-1)
// Grab a reasonable window after the match
const snippet = text.slice(start, start + 600)
expect(snippet, "must call pushEmptyState when state is absent").toContain("pushEmptyState")
expect(snippet, "must guard on this.state being falsy").toMatch(/!this\.state/)
})
it("requestState handler calls pushState when this.state is truthy", () => {
const text = body("onMessage")
const start = text.indexOf('"agentManager.requestState"')
const snippet = text.slice(start, start + 600)
expect(snippet, "must call pushState for the normal path").toContain("this.pushState()")
})
})
// ---------------------------------------------------------------------------
// Webview — non-git skeleton fix
// ---------------------------------------------------------------------------
describe("Agent Manager Webview — non-git sessionsLoaded fix", () => {
const tsx = readAllTsx()
/**
* Regression: when isGitRepo is false, the Kilo server never sends a
* "sessionsLoaded" message, so the skeleton was stuck forever.
* The fix must set sessionsLoaded(true) when receiving a state message
* with isGitRepo === false.
*/
it("sets sessionsLoaded when agentManager.state arrives with isGitRepo false", () => {
// Find the agentManager.state handler block
const start = tsx.indexOf('"agentManager.state"')
expect(start, "agentManager.state handler must exist").toBeGreaterThan(-1)
const snippet = tsx.slice(start, start + 800)
expect(snippet, "must call setSessionsLoaded in the non-git branch").toContain("setSessionsLoaded")
expect(snippet, "must check isGitRepo === false before setting sessionsLoaded").toMatch(
/isGitRepo.*false|false.*isGitRepo/,
)
})
})
@@ -0,0 +1,397 @@
import { describe, expect, it } from "bun:test"
import {
parsePRUrl,
localBranchName,
parseForEachRefOutput,
buildBranchList,
parseWorktreeList,
checkedOutBranchesFromWorktreeList,
classifyPRError,
validateGitRef,
} from "../../src/agent-manager/git-import"
// ---------------------------------------------------------------------------
// parsePRUrl
// ---------------------------------------------------------------------------
describe("parsePRUrl", () => {
it("parses a standard GitHub PR URL", () => {
expect(parsePRUrl("https://github.com/Kilo-Org/kilocode/pull/6164")).toEqual({
owner: "Kilo-Org",
repo: "kilocode",
number: 6164,
})
})
it("handles URL without protocol", () => {
expect(parsePRUrl("github.com/owner/repo/pull/42")).toEqual({
owner: "owner",
repo: "repo",
number: 42,
})
})
it("handles trailing slashes", () => {
expect(parsePRUrl("https://github.com/o/r/pull/1///")).toEqual({
owner: "o",
repo: "r",
number: 1,
})
})
it("handles www.github.com", () => {
expect(parsePRUrl("https://www.github.com/o/r/pull/99")).toEqual({
owner: "o",
repo: "r",
number: 99,
})
})
it("returns null for non-GitHub URLs", () => {
expect(parsePRUrl("https://gitlab.com/owner/repo/pull/1")).toBeNull()
})
it("returns null for GitHub URLs without /pull/", () => {
expect(parsePRUrl("https://github.com/owner/repo/issues/1")).toBeNull()
})
it("returns null for empty string", () => {
expect(parsePRUrl("")).toBeNull()
})
it("returns null for garbage input", () => {
expect(parsePRUrl("not a url at all")).toBeNull()
})
it("returns null when PR number is missing", () => {
expect(parsePRUrl("https://github.com/owner/repo/pull/")).toBeNull()
})
it("strips whitespace", () => {
expect(parsePRUrl(" https://github.com/o/r/pull/5 ")).toEqual({
owner: "o",
repo: "r",
number: 5,
})
})
it("handles URL with extra path segments after PR number", () => {
const result = parsePRUrl("https://github.com/o/r/pull/123/files")
expect(result).toEqual({ owner: "o", repo: "r", number: 123 })
})
it("rejects lookalike domains", () => {
expect(parsePRUrl("https://evilgithub.com/owner/repo/pull/1")).toBeNull()
expect(parsePRUrl("https://notgithub.com/owner/repo/pull/1")).toBeNull()
})
})
// ---------------------------------------------------------------------------
// localBranchName
// ---------------------------------------------------------------------------
describe("localBranchName", () => {
it("returns headRefName for same-repo PR", () => {
expect(
localBranchName({
headRefName: "feat/cool",
isCrossRepository: false,
title: "test",
}),
).toBe("feat/cool")
})
it("prefixes with fork owner for cross-repo PR", () => {
expect(
localBranchName({
headRefName: "fix/bug",
headRepositoryOwner: { login: "Contributor" },
isCrossRepository: true,
title: "test",
}),
).toBe("contributor/fix/bug")
})
it("returns headRefName if cross-repo but no owner", () => {
expect(
localBranchName({
headRefName: "fix/bug",
isCrossRepository: true,
title: "test",
}),
).toBe("fix/bug")
})
})
// ---------------------------------------------------------------------------
// parseForEachRefOutput
// ---------------------------------------------------------------------------
describe("parseForEachRefOutput", () => {
it("parses local and remote branches with dates", () => {
const raw = [
"refs/heads/main\t2025-01-15T10:00:00+00:00",
"refs/heads/feat/a\t2025-01-14T09:00:00+00:00",
"refs/remotes/origin/main\t2025-01-15T10:00:00+00:00",
"refs/remotes/origin/feat/b\t2025-01-13T08:00:00+00:00",
].join("\n")
const { locals, remotes, dates } = parseForEachRefOutput(raw)
expect([...locals]).toEqual(["main", "feat/a"])
expect([...remotes]).toEqual(["main", "feat/b"])
expect(dates.get("main")).toBe("2025-01-15T10:00:00+00:00")
expect(dates.get("feat/a")).toBe("2025-01-14T09:00:00+00:00")
expect(dates.get("feat/b")).toBe("2025-01-13T08:00:00+00:00")
})
it("skips HEAD entries", () => {
const raw = "refs/remotes/origin/HEAD\t2025-01-01T00:00:00+00:00\nrefs/heads/main\t2025-01-01T00:00:00+00:00"
const { locals, remotes } = parseForEachRefOutput(raw)
expect(remotes.has("HEAD")).toBe(false)
expect(locals.has("main")).toBe(true)
})
it("skips empty lines", () => {
const raw = "\n\nrefs/heads/main\t2025-01-01T00:00:00+00:00\n\n"
const { locals } = parseForEachRefOutput(raw)
expect([...locals]).toEqual(["main"])
})
it("handles empty output", () => {
const { locals, remotes, dates } = parseForEachRefOutput("")
expect(locals.size).toBe(0)
expect(remotes.size).toBe(0)
expect(dates.size).toBe(0)
})
it("local branch takes priority for date when both exist", () => {
const raw = [
"refs/heads/main\t2025-02-01T00:00:00+00:00",
"refs/remotes/origin/main\t2025-01-01T00:00:00+00:00",
].join("\n")
const { dates } = parseForEachRefOutput(raw)
expect(dates.get("main")).toBe("2025-02-01T00:00:00+00:00")
})
})
// ---------------------------------------------------------------------------
// buildBranchList
// ---------------------------------------------------------------------------
describe("buildBranchList", () => {
it("merges local and remote into deduplicated sorted list", () => {
const locals = new Set(["main", "feat/a"])
const remotes = new Set(["main", "feat/b"])
const dates = new Map([
["main", "2025-01-15T00:00:00+00:00"],
["feat/a", "2025-01-14T00:00:00+00:00"],
["feat/b", "2025-01-13T00:00:00+00:00"],
])
const result = buildBranchList(locals, remotes, dates, "main")
expect(result[0].name).toBe("main")
expect(result[0].isDefault).toBe(true)
expect(result[0].isLocal).toBe(true)
expect(result[0].isRemote).toBe(true)
expect(result[1].name).toBe("feat/a")
expect(result[1].isLocal).toBe(true)
expect(result[1].isRemote).toBe(false)
expect(result[2].name).toBe("feat/b")
expect(result[2].isLocal).toBe(false)
expect(result[2].isRemote).toBe(true)
})
it("sorts default branch first", () => {
const locals = new Set(["z-branch", "main"])
const dates = new Map([
["z-branch", "2099-01-01T00:00:00+00:00"],
["main", "2020-01-01T00:00:00+00:00"],
])
const result = buildBranchList(locals, new Set(), dates, "main")
expect(result[0].name).toBe("main")
})
it("sorts by date descending after default", () => {
const locals = new Set(["old", "new", "mid"])
const dates = new Map([
["old", "2020-01-01T00:00:00+00:00"],
["new", "2025-01-01T00:00:00+00:00"],
["mid", "2023-01-01T00:00:00+00:00"],
])
const result = buildBranchList(locals, new Set(), dates, "none")
expect(result.map((b) => b.name)).toEqual(["new", "mid", "old"])
})
it("handles empty inputs", () => {
expect(buildBranchList(new Set(), new Set(), new Map(), "main")).toEqual([])
})
})
// ---------------------------------------------------------------------------
// parseWorktreeList
// ---------------------------------------------------------------------------
describe("parseWorktreeList", () => {
it("parses standard worktree entries", () => {
const raw = [
"worktree /home/user/repo",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /home/user/repo/.worktrees/feat",
"HEAD def456",
"branch refs/heads/feat/cool",
"",
].join("\n")
const entries = parseWorktreeList(raw)
expect(entries).toHaveLength(2)
expect(entries[0]).toEqual({ path: "/home/user/repo", branch: "main", bare: false, detached: false })
expect(entries[1]).toEqual({
path: "/home/user/repo/.worktrees/feat",
branch: "feat/cool",
bare: false,
detached: false,
})
})
it("detects bare worktree", () => {
const raw = "worktree /home/user/repo\nHEAD abc\nbare\n\n"
const entries = parseWorktreeList(raw)
expect(entries[0].bare).toBe(true)
})
it("detects detached HEAD", () => {
const raw = "worktree /home/user/repo/.wt/fix\nHEAD abc123\ndetached\n\n"
const entries = parseWorktreeList(raw)
expect(entries[0].detached).toBe(true)
expect(entries[0].branch).toBe("(detached)")
})
it("handles empty output", () => {
expect(parseWorktreeList("")).toEqual([])
expect(parseWorktreeList("\n\n")).toEqual([])
})
it("handles missing branch line with unknown", () => {
const raw = "worktree /some/path\nHEAD abc\n\n"
const entries = parseWorktreeList(raw)
expect(entries[0].branch).toBe("unknown")
})
})
// ---------------------------------------------------------------------------
// checkedOutBranchesFromWorktreeList
// ---------------------------------------------------------------------------
describe("checkedOutBranchesFromWorktreeList", () => {
it("returns branches from non-bare non-detached entries", () => {
const raw = [
"worktree /repo",
"HEAD abc",
"branch refs/heads/main",
"",
"worktree /repo/.wt/feat",
"HEAD def",
"branch refs/heads/feat/x",
"",
"worktree /repo/.wt/detached",
"HEAD ghi",
"detached",
"",
].join("\n")
const branches = checkedOutBranchesFromWorktreeList(raw)
expect(branches.has("main")).toBe(true)
expect(branches.has("feat/x")).toBe(true)
expect(branches.has("(detached)")).toBe(false)
expect(branches.size).toBe(2)
})
it("excludes bare entries", () => {
const raw = "worktree /repo\nHEAD abc\nbare\n\n"
expect(checkedOutBranchesFromWorktreeList(raw).size).toBe(0)
})
it("handles empty input", () => {
expect(checkedOutBranchesFromWorktreeList("").size).toBe(0)
})
})
// ---------------------------------------------------------------------------
// classifyPRError
// ---------------------------------------------------------------------------
describe("classifyPRError", () => {
it("detects PR not found", () => {
expect(classifyPRError("GraphQL: Could not resolve to a PullRequest")).toBe("not_found")
expect(classifyPRError("not found")).toBe("not_found")
})
it("detects gh CLI missing", () => {
expect(classifyPRError("gh: command not found")).toBe("gh_missing")
expect(classifyPRError("spawn gh ENOENT")).toBe("gh_missing")
expect(classifyPRError("'gh' is not recognized as an internal command")).toBe("gh_missing")
})
it("detects gh auth issue", () => {
expect(classifyPRError("not logged into any github hosts")).toBe("gh_auth")
expect(classifyPRError("please run `gh auth login`")).toBe("gh_auth")
})
it("returns unknown for unrecognized errors", () => {
expect(classifyPRError("something went wrong")).toBe("unknown")
})
})
// ---------------------------------------------------------------------------
// validateGitRef
// ---------------------------------------------------------------------------
describe("validateGitRef", () => {
it("accepts simple branch names", () => {
expect(() => validateGitRef("main", "branch")).not.toThrow()
expect(() => validateGitRef("feat/cool", "branch")).not.toThrow()
expect(() => validateGitRef("fix-123", "branch")).not.toThrow()
expect(() => validateGitRef("v1.2.3", "branch")).not.toThrow()
})
it("accepts usernames with dots, hyphens, underscores", () => {
expect(() => validateGitRef("some-user", "owner")).not.toThrow()
expect(() => validateGitRef("user.name", "owner")).not.toThrow()
expect(() => validateGitRef("user_name", "owner")).not.toThrow()
})
it("rejects values starting with a dash (git flag injection)", () => {
expect(() => validateGitRef("--upload-pack=evil", "ref")).toThrow('Unsafe ref: "--upload-pack=evil"')
expect(() => validateGitRef("-b", "ref")).toThrow('Unsafe ref: "-b"')
})
it("rejects empty strings", () => {
expect(() => validateGitRef("", "ref")).toThrow('Unsafe ref: ""')
})
it("rejects values with spaces", () => {
expect(() => validateGitRef("bad name", "ref")).toThrow()
})
it("rejects values with shell metacharacters", () => {
expect(() => validateGitRef("$(whoami)", "ref")).toThrow()
expect(() => validateGitRef("foo;rm -rf /", "ref")).toThrow()
expect(() => validateGitRef("foo`id`", "ref")).toThrow()
expect(() => validateGitRef("foo|bar", "ref")).toThrow()
})
it("rejects values with newlines", () => {
expect(() => validateGitRef("foo\nbar", "ref")).toThrow()
})
it("rejects values containing .. (git ref traversal)", () => {
expect(() => validateGitRef("foo/../bar", "ref")).toThrow()
expect(() => validateGitRef("..hidden", "ref")).toThrow()
})
})
@@ -545,3 +545,80 @@ describe("WorktreeManager.removeWorktree safety", () => {
expect(exists).toBe(true)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- listBranches
// ---------------------------------------------------------------------------
describe("WorktreeManager.listBranches", () => {
it("returns the current branch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const { branches, defaultBranch } = await mgr.listBranches()
const names = branches.map((b) => b.name)
const git = simpleGit(root)
const current = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
expect(names).toContain(current)
expect(defaultBranch).toBeTruthy()
})
it("includes branches created after init", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
await git.branch(["feature-test"])
const mgr = createManager(root)
const { branches } = await mgr.listBranches()
expect(branches.map((b) => b.name)).toContain("feature-test")
})
it("marks local branches as isLocal", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const { branches } = await mgr.listBranches()
for (const b of branches) {
expect(b.isLocal).toBe(true)
}
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- checkedOutBranches
// ---------------------------------------------------------------------------
describe("WorktreeManager.checkedOutBranches", () => {
it("includes the main branch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const checked = await mgr.checkedOutBranches()
const git = simpleGit(root)
const current = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
expect(checked.has(current)).toBe(true)
})
it("includes worktree branches", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const wt = await mgr.createWorktree({ prompt: "checked-out-test" })
const checked = await mgr.checkedOutBranches()
expect(checked.has(wt.branch)).toBe(true)
})
it("excludes branches after worktree removal", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const wt = await mgr.createWorktree({ prompt: "removal-test" })
await mgr.removeWorktree(wt.path)
const checked = await mgr.checkedOutBranches()
expect(checked.has(wt.branch)).toBe(false)
})
})
@@ -19,10 +19,12 @@ import type {
AgentManagerKeybindingsMessage,
AgentManagerMultiVersionProgressMessage,
AgentManagerSendInitialMessage,
AgentManagerBranchInfo,
AgentManagerBranchesMessage,
AgentManagerImportResultMessage,
WorktreeState,
ManagedSessionState,
SessionInfo,
BranchInfo,
} from "../src/types/messages"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -35,13 +37,14 @@ import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Toast } from "@kilocode/kilo-ui/toast"
import { Toast, showToast } from "@kilocode/kilo-ui/toast"
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, 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"
@@ -657,6 +660,9 @@ const AgentManagerContent: Component = () => {
setManagedSessions(state.sessions)
if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo)
if (!worktreesLoaded()) setWorktreesLoaded(true)
// When not a git repo, also mark sessions as loaded since the Kilo
// server won't connect to send the sessionsLoaded message.
if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
const current = session.currentSessionID()
if (current) {
@@ -1563,12 +1569,14 @@ const AgentManagerContent: Component = () => {
}
// ---------------------------------------------------------------------------
// Advanced "New Worktree" dialog — prompt, versions, model, mode, advanced options
// Advanced "New Worktree" dialog — prompt, versions, model, mode, import tab
// ---------------------------------------------------------------------------
type VersionCount = 1 | 2 | 3 | 4
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
type DialogTab = "new" | "import"
function sanitizeSegment(text: string, maxLength = 50): string {
return text
.toLowerCase()
@@ -1591,20 +1599,19 @@ function sanitizeBranchName(name: string): string {
.join("/")
}
function formatRelativeTime(epoch: number): string {
const diff = Math.floor(Date.now() / 1000) - epoch
if (diff < 60) return "now"
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
if (diff < 2592000) return `${Math.floor(diff / 86400)}d`
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo`
return `${Math.floor(diff / 31536000)}y`
}
const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
const vscode = useVSCode()
const session = useSession()
const [tab, setTab] = createSignal<DialogTab>("new")
// --- Shared branch data (used by both New tab's base branch selector and Import tab) ---
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [branchesLoading, setBranchesLoading] = createSignal(false)
const [defaultBranch, setDefaultBranch] = createSignal("main")
const [branchSearch, setBranchSearch] = createSignal("")
// --- New tab state ---
const [name, setName] = createSignal("")
const [prompt, setPrompt] = createSignal("")
const [versions, setVersions] = createSignal<VersionCount>(1)
@@ -1614,9 +1621,6 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
const [showAdvanced, setShowAdvanced] = createSignal(false)
const [branchName, setBranchName] = createSignal("")
const [baseBranch, setBaseBranch] = createSignal<string | null>(null)
const [branches, setBranches] = createSignal<AgentManagerBranchInfo[]>([])
const [defaultBranch, setDefaultBranch] = createSignal("main")
const [branchSearch, setBranchSearch] = createSignal("")
const [baseBranchOpen, setBaseBranchOpen] = createSignal(false)
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
@@ -1628,20 +1632,10 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
textareaRef.focus()
textareaRef.select()
})
setBranchesLoading(true)
vscode.postMessage({ type: "agentManager.requestBranches" })
})
// Listen for branch data
const handler = (e: MessageEvent) => {
const msg = e.data as ExtensionMessage
if (msg.type === "agentManager.branches") {
setBranches(msg.branches)
setDefaultBranch(msg.defaultBranch)
}
}
window.addEventListener("message", handler)
onCleanup(() => window.removeEventListener("message", handler))
const effectiveBaseBranch = () => baseBranch() ?? defaultBranch()
const filteredBranches = createMemo(() => {
@@ -1692,214 +1686,399 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
}
// --- Import tab state ---
const [prUrl, setPrUrl] = createSignal("")
const [prPending, setPrPending] = createSignal(false)
const [branchOpen, setBranchOpen] = createSignal(false)
const [importPending, setImportPending] = createSignal(false)
const isPending = () => prPending() || importPending()
// Listen for branch data + import results
const importUnsub = vscode.onMessage((msg) => {
if (msg.type === "agentManager.branches") {
const ev = msg as AgentManagerBranchesMessage
setBranches(ev.branches)
setDefaultBranch(ev.defaultBranch)
setBranchesLoading(false)
}
if (msg.type === "agentManager.importResult") {
const ev = msg as AgentManagerImportResultMessage
setPrPending(false)
setImportPending(false)
if (ev.success) {
props.onClose()
} else {
showToast({ variant: "error", title: "Import failed", description: ev.message })
}
}
})
onCleanup(() => importUnsub())
const handlePRSubmit = () => {
const url = prUrl().trim()
if (!url || isPending()) return
setPrPending(true)
vscode.postMessage({ type: "agentManager.importFromPR", url })
}
const handleBranchSelect = (name: string) => {
if (isPending()) return
setImportPending(true)
setBranchOpen(false)
setBranchSearch("")
vscode.postMessage({ type: "agentManager.importFromBranch", branch: name })
}
return (
<Dialog title="New Worktree" fit>
<div class="am-nv-dialog" onKeyDown={handleKeyDown}>
{/* Optional worktree name */}
<input
class="am-nv-name-input"
placeholder="Worktree name (optional)"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
{/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */}
<div class="prompt-input-container am-prompt-input-container">
<div class="prompt-input-wrapper am-prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper am-prompt-input-ghost-wrapper">
<textarea
ref={textareaRef}
class="prompt-input am-prompt-input"
placeholder={`Type a message (${isMac ? "\u2318" : "Ctrl+"}Enter to send)`}
value={prompt()}
onInput={(e) => {
setPrompt(e.currentTarget.value)
adjustHeight()
}}
rows={3}
/>
</div>
</div>
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => setModel(pid && mid ? { providerID: pid, modelID: mid } : null)}
placement="top-start"
allowClear
clearLabel="Default"
/>
<Show when={session.agents().length > 1}>
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
</Show>
</div>
<div class="prompt-input-hint-actions" />
</div>
</div>
{/* Advanced options toggle */}
<button class="am-advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced())} type="button">
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
<span>Advanced options</span>
<Dialog title="Open Workspace" fit>
{/* Tab switcher */}
<div class="am-tab-switcher">
<button
class="am-tab-switcher-pill"
classList={{ "am-tab-switcher-pill-active": tab() === "new" }}
onClick={() => setTab("new")}
type="button"
>
New
</button>
<button
class="am-tab-switcher-pill"
classList={{ "am-tab-switcher-pill-active": tab() === "import" }}
onClick={() => setTab("import")}
type="button"
>
Import
</button>
</div>
<Show when={showAdvanced()}>
<div class="am-advanced-section">
<div class="am-advanced-field">
<span class="am-nv-config-label">Branch name</span>
<input
class="am-advanced-input"
type="text"
placeholder="auto-generated"
value={branchName()}
onInput={(e) => setBranchName(sanitizeBranchName(e.currentTarget.value))}
/>
{/* New tab */}
<Show when={tab() === "new"}>
<div class="am-nv-dialog" onKeyDown={handleKeyDown}>
<input
class="am-nv-name-input"
placeholder="Worktree name (optional)"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
{/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */}
<div class="prompt-input-container am-prompt-input-container">
<div class="prompt-input-wrapper am-prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper am-prompt-input-ghost-wrapper">
<textarea
ref={textareaRef}
class="prompt-input am-prompt-input"
placeholder={`Type a message (${isMac ? "\u2318" : "Ctrl+"}Enter to send)`}
value={prompt()}
onInput={(e) => {
setPrompt(e.currentTarget.value)
adjustHeight()
}}
rows={3}
/>
</div>
</div>
<div class="am-advanced-field">
<span class="am-nv-config-label">Base branch</span>
<div class="am-branch-selector-wrapper">
<button
class="am-branch-selector-trigger"
onClick={() => setBaseBranchOpen(!baseBranchOpen())}
type="button"
>
<Icon name="branch" size="small" />
<span class="am-branch-selector-value">{effectiveBaseBranch()}</span>
<Show when={!baseBranch()}>
<span class="am-branch-badge">default</span>
</Show>
<Icon name="selector" size="small" />
</button>
<Show when={baseBranchOpen()}>
<div class="am-branch-dropdown" onWheel={(e) => e.stopPropagation()}>
<div class="am-branch-search">
<Icon name="magnifying-glass" size="small" />
<input
class="am-branch-search-input"
type="text"
placeholder="Search branches..."
value={branchSearch()}
ref={(el) => requestAnimationFrame(() => el.focus())}
onInput={(e) => {
setBranchSearch(e.currentTarget.value)
setHighlightedIndex(0)
}}
onKeyDown={(e) => {
const items = filteredBranches()
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
const next = Math.min(highlightedIndex() + 1, items.length - 1)
setHighlightedIndex(next)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${next}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
const prev = Math.max(highlightedIndex() - 1, 0)
setHighlightedIndex(prev)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${prev}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
const selected = items[highlightedIndex()]
if (selected) {
setBaseBranch(selected.name)
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => setModel(pid && mid ? { providerID: pid, modelID: mid } : null)}
placement="top-start"
allowClear
clearLabel="Default"
/>
<Show when={session.agents().length > 1}>
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
</Show>
</div>
<div class="prompt-input-hint-actions" />
</div>
</div>
{/* Advanced options toggle */}
<button class="am-advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced())} type="button">
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
<span>Advanced options</span>
</button>
<Show when={showAdvanced()}>
<div class="am-advanced-section">
<div class="am-advanced-field">
<span class="am-nv-config-label">Branch name</span>
<input
class="am-advanced-input"
type="text"
placeholder="auto-generated"
value={branchName()}
onInput={(e) => setBranchName(sanitizeBranchName(e.currentTarget.value))}
/>
</div>
<div class="am-advanced-field">
<span class="am-nv-config-label">Base branch</span>
<div class="am-branch-selector-wrapper">
<button
class="am-branch-selector-trigger"
onClick={() => setBaseBranchOpen(!baseBranchOpen())}
type="button"
>
<Icon name="branch" size="small" />
<span class="am-branch-selector-value">{effectiveBaseBranch()}</span>
<Show when={!baseBranch()}>
<span class="am-branch-badge">default</span>
</Show>
<Icon name="selector" size="small" />
</button>
<Show when={baseBranchOpen()}>
<div class="am-branch-dropdown" onWheel={(e) => e.stopPropagation()}>
<div class="am-branch-search">
<Icon name="magnifying-glass" size="small" />
<input
class="am-branch-search-input"
type="text"
placeholder="Search branches..."
value={branchSearch()}
ref={(el) => requestAnimationFrame(() => el.focus())}
onInput={(e) => {
setBranchSearch(e.currentTarget.value)
setHighlightedIndex(0)
}}
onKeyDown={(e) => {
const items = filteredBranches()
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
const next = Math.min(highlightedIndex() + 1, items.length - 1)
setHighlightedIndex(next)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${next}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
const prev = Math.max(highlightedIndex() - 1, 0)
setHighlightedIndex(prev)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${prev}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
const selected = items[highlightedIndex()]
if (selected) {
setBaseBranch(selected.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
}}
/>
}}
/>
</div>
<div class="am-branch-list">
<For each={filteredBranches()}>
{(branch, index) => (
<button
class="am-branch-item"
classList={{
"am-branch-item-active": effectiveBaseBranch() === branch.name,
"am-branch-item-highlighted": highlightedIndex() === index(),
}}
data-index={index()}
onClick={() => {
setBaseBranch(branch.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}}
onMouseEnter={() => setHighlightedIndex(index())}
type="button"
>
<Icon name="branch" size="small" />
<span class="am-branch-item-name">{branch.name}</span>
<Show when={branch.isDefault}>
<span class="am-branch-badge">default</span>
</Show>
<Show when={!branch.isLocal && branch.isRemote}>
<span class="am-branch-badge am-branch-badge-remote">remote</span>
</Show>
<Show when={branch.lastCommitDate}>
<span class="am-branch-item-time">{formatRelativeDate(branch.lastCommitDate!)}</span>
</Show>
</button>
)}
</For>
</div>
</div>
<div class="am-branch-list">
<For each={filteredBranches()}>
{(branch, index) => (
<button
class="am-branch-item"
classList={{
"am-branch-item-active": effectiveBaseBranch() === branch.name,
"am-branch-item-highlighted": highlightedIndex() === index(),
}}
data-index={index()}
onClick={() => {
setBaseBranch(branch.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}}
onMouseEnter={() => setHighlightedIndex(index())}
type="button"
>
</Show>
</div>
</div>
</div>
</Show>
{/* Version selector + info */}
<div class="am-nv-version-bar">
<span class="am-nv-config-label">Versions</span>
<div class="am-nv-pills">
{VERSION_OPTIONS.map((count) => (
<button
class="am-nv-pill"
classList={{ "am-nv-pill-active": versions() === count }}
onClick={() => setVersions(count)}
type="button"
>
{count}
</button>
))}
</div>
<Show when={versions() > 1}>
<span class="am-nv-version-hint">{versions()} worktrees will run in parallel</span>
</Show>
</div>
{/* Submit button */}
<Button variant="primary" size="large" class="am-nv-submit" onClick={handleSubmit} disabled={!canSubmit()}>
<Show
when={!starting()}
fallback={
<>
<Spinner class="am-nv-spinner" />
<span>Creating...</span>
</>
}
>
Create Workspace
</Show>
</Button>
</div>
</Show>
{/* Import tab */}
<Show when={tab() === "import"}>
<div class="am-import-tab">
{/* Pull Request section */}
<div class="am-import-section">
<span class="am-nv-config-label">Pull Request</span>
<div class="am-pr-row">
<div class="am-pr-input-wrapper">
<Icon name="branch" size="small" />
<input
class="am-pr-input"
type="text"
placeholder="Paste PR URL..."
value={prUrl()}
onInput={(e) => setPrUrl(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handlePRSubmit()
}
}}
disabled={isPending()}
/>
</div>
<Button
variant="secondary"
size="small"
onClick={handlePRSubmit}
disabled={!prUrl().trim() || isPending()}
>
<Show when={prPending()} fallback="Open">
<Spinner class="am-nv-spinner" />
</Show>
</Button>
</div>
</div>
<div class="am-import-divider" />
{/* Branches section */}
<div class="am-import-section">
<span class="am-nv-config-label">Branches</span>
<div class="am-selector-wrapper">
<Popover
open={branchOpen()}
onOpenChange={setBranchOpen}
placement="bottom-start"
sameWidth
class="am-dropdown"
trigger={
<button class="am-selector-trigger" disabled={isPending()} type="button">
<span class="am-selector-left">
<Icon name="branch" size="small" />
<span class="am-selector-value">{branchesLoading() ? "Loading..." : "Select branch..."}</span>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<div class="am-dropdown-search">
<Icon name="magnifying-glass" size="small" />
<input
class="am-dropdown-search-input"
type="text"
placeholder="Search branches..."
value={branchSearch()}
onInput={(e) => setBranchSearch(e.currentTarget.value)}
autofocus
/>
</div>
<div class="am-dropdown-list">
<Show
when={filteredBranches().length > 0}
fallback={
<div class="am-dropdown-empty">
{branchesLoading() ? "Loading branches..." : "No matching branches"}
</div>
}
>
<For each={filteredBranches()}>
{(branch) => (
<div class="am-branch-item" onClick={() => handleBranchSelect(branch.name)}>
<span class="am-branch-item-left">
<Icon name="branch" size="small" />
<span class="am-branch-item-name">{branch.name}</span>
<Show when={branch.isDefault}>
<span class="am-branch-badge">default</span>
</Show>
<Show when={!branch.isLocal && branch.isRemote}>
<span class="am-branch-badge am-branch-badge-remote">remote</span>
<span class="am-branch-badge">remote</span>
</Show>
<span class="am-branch-item-time">{formatRelativeTime(branch.lastCommitDate)}</span>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
</span>
<Show when={branch.lastCommitDate}>
<span class="am-branch-item-time">{formatRelativeDate(branch.lastCommitDate!)}</span>
</Show>
</div>
)}
</For>
</Show>
</div>
</Popover>
</div>
</div>
</Show>
{/* Version selector + info */}
<div class="am-nv-version-bar">
<span class="am-nv-config-label">Versions</span>
<div class="am-nv-pills">
{VERSION_OPTIONS.map((count) => (
<button
class="am-nv-pill"
classList={{ "am-nv-pill-active": versions() === count }}
onClick={() => setVersions(count)}
type="button"
>
{count}
</button>
))}
</div>
<Show when={versions() > 1}>
<span class="am-nv-version-hint">{versions()} worktrees will run in parallel</span>
{/* Empty state when no branches are available */}
<Show when={!branchesLoading() && branches().length === 0}>
<div class="am-import-empty">
No branches found.
<br />
Paste a PR URL above or create a new worktree.
</div>
</Show>
</div>
{/* Submit button */}
<Button variant="primary" size="large" class="am-nv-submit" onClick={handleSubmit} disabled={!canSubmit()}>
<Show
when={!starting()}
fallback={
<>
<Spinner class="am-nv-spinner" />
<span>Creating...</span>
</>
}
>
Create Workspace
</Show>
</Button>
</div>
</Show>
</Dialog>
)
}
@@ -1387,3 +1387,294 @@ button.am-section-toggle:hover .am-section-label {
.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
animation-delay: 0.3s;
}
/* Tab switcher for New/Import tabs in the dialog */
.am-tab-switcher {
display: flex;
gap: 2px;
padding: 0 20px;
margin-bottom: 4px;
}
.am-tab-switcher-pill {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 14px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-weak);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 100ms;
}
.am-tab-switcher-pill:hover {
background: var(--surface-inset-base-hover);
color: var(--text-base);
}
.am-tab-switcher-pill-active {
background: var(--surface-interactive-base) !important;
color: var(--text-on-interactive-base) !important;
}
/* Import tab layout */
.am-import-tab {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px 16px;
min-width: 460px;
max-height: 350px;
overflow-y: auto;
}
.am-import-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.am-import-divider {
height: 1px;
background: var(--border-weak-base);
}
/* PR URL input row */
.am-pr-row {
display: flex;
align-items: center;
gap: 8px;
}
.am-pr-input-wrapper {
flex: 1;
position: relative;
}
.am-pr-input-wrapper [data-component="icon"] {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-weaker);
pointer-events: none;
}
.am-pr-input {
width: 100%;
height: 32px;
padding: 0 12px 0 32px;
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-sm);
background: var(--vscode-input-background, var(--surface-inset-base));
color: var(--vscode-input-foreground, var(--text-base));
font-size: var(--font-size-base);
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.am-pr-input:focus {
border-color: var(--border-focus, #007fd4);
}
.am-pr-input::placeholder {
color: var(--vscode-input-placeholderForeground, var(--text-weaker));
}
.am-pr-input:disabled {
opacity: 0.5;
}
/* Branch/worktree selector — popover trigger must be full-width */
.am-selector-wrapper [data-slot="popover-trigger"] {
display: flex;
width: 100%;
}
.am-selector-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-sm);
background: var(--vscode-input-background, var(--surface-inset-base));
color: var(--text-base);
font-size: var(--font-size-base);
font-weight: 400;
cursor: pointer;
text-align: left;
}
.am-selector-trigger:hover {
border-color: var(--text-weaker);
}
.am-selector-trigger:disabled {
opacity: 0.5;
cursor: default;
}
.am-selector-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-shrink: 1;
}
.am-selector-left [data-component="icon"] {
color: var(--text-weaker);
flex-shrink: 0;
}
.am-selector-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-weaker);
}
.am-selector-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: 8px;
}
.am-selector-right [data-component="icon"] {
color: var(--text-weaker);
}
/* Dropdown popover — must override kilo-ui defaults for max-width and body padding */
.am-dropdown[data-component="popover-content"] {
max-width: none;
width: var(--kb-popper-anchor-width);
}
.am-dropdown [data-slot="popover-body"] {
padding: 0;
}
.am-dropdown-search {
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
border-bottom: 1px solid var(--border-weak-base);
}
.am-dropdown-search [data-component="icon"] {
color: var(--text-weaker);
flex-shrink: 0;
opacity: 0.5;
}
.am-dropdown-search-input {
flex: 1;
border: none;
background: none;
color: var(--vscode-input-foreground, var(--text-base));
font-size: var(--font-size-base);
font-family: inherit;
outline: none;
min-width: 0;
}
.am-dropdown-search-input::placeholder {
color: var(--vscode-input-placeholderForeground, var(--text-weaker));
}
.am-dropdown-list {
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
/* Branch item in dropdown */
.am-branch-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-base);
}
.am-branch-item:hover {
background: var(--surface-inset-base-hover);
}
.am-branch-item-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.am-branch-item-left [data-component="icon"] {
color: var(--text-weaker);
flex-shrink: 0;
}
.am-branch-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.am-branch-item-time {
font-size: 11px;
color: var(--text-weaker);
white-space: nowrap;
flex-shrink: 0;
margin-left: 8px;
}
.am-branch-badge {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
background: var(--surface-inset-base);
color: var(--text-weaker);
white-space: nowrap;
flex-shrink: 0;
}
.am-dropdown-empty {
padding: 16px;
text-align: center;
font-size: 12px;
color: var(--text-weaker);
}
/* Import empty state */
.am-import-empty {
padding: 20px;
text-align: center;
font-size: 12px;
color: var(--text-weaker);
line-height: 1.5;
}
@@ -649,28 +649,44 @@ export interface AgentManagerMultiVersionProgressMessage {
groupId?: string
}
// Branch info for branch selector (extension → webview)
export interface AgentManagerBranchInfo {
name: string
isLocal: boolean
isRemote: boolean
lastCommitDate: number
isDefault: boolean
}
// Branch list response (extension → webview)
export interface AgentManagerBranchesMessage {
type: "agentManager.branches"
branches: AgentManagerBranchInfo[]
defaultBranch: string
}
// Stored variant selections loaded from extension globalState (extension → webview)
export interface VariantsLoadedMessage {
type: "variantsLoaded"
variants: Record<string, string>
}
export interface BranchInfo {
name: string
isLocal: boolean
isRemote: boolean
isDefault: boolean
lastCommitDate?: string
}
export interface AgentManagerBranchesMessage {
type: "agentManager.branches"
branches: BranchInfo[]
defaultBranch: string
}
// Agent Manager Import tab: external worktrees (extension → webview)
export interface ExternalWorktreeInfo {
path: string
branch: string
}
export interface AgentManagerExternalWorktreesMessage {
type: "agentManager.externalWorktrees"
worktrees: ExternalWorktreeInfo[]
}
// Agent Manager Import tab: result feedback (extension → webview)
export interface AgentManagerImportResultMessage {
type: "agentManager.importResult"
success: boolean
message: string
}
// Request webview to send initial prompt to a newly created session (extension → webview)
export interface AgentManagerSendInitialMessage {
type: "agentManager.sendInitialMessage"
@@ -733,6 +749,9 @@ export type ExtensionMessage =
| CloudSessionDataLoadedMessage
| CloudSessionImportedMessage
| CloudSessionImportFailedMessage
| AgentManagerBranchesMessage
| AgentManagerExternalWorktreesMessage
| AgentManagerImportResultMessage
// ============================================
// Messages FROM webview TO extension
@@ -1048,11 +1067,33 @@ export interface SetSessionsCollapsedRequest {
collapsed: boolean
}
// Request branch list for base branch selector
export interface RequestBranchesMessage {
type: "agentManager.requestBranches"
}
export interface RequestExternalWorktreesMessage {
type: "agentManager.requestExternalWorktrees"
}
export interface ImportFromBranchRequest {
type: "agentManager.importFromBranch"
branch: string
}
export interface ImportFromPRRequest {
type: "agentManager.importFromPR"
url: string
}
export interface ImportExternalWorktreeRequest {
type: "agentManager.importExternalWorktree"
path: string
branch: string
}
export interface ImportAllExternalWorktreesRequest {
type: "agentManager.importAllExternalWorktrees"
}
// Variant persistence (webview → extension)
export interface PersistVariantRequest {
type: "persistVariant"
@@ -1124,6 +1165,11 @@ export type WebviewMessage =
| RequestCloudSessionDataMessage
| ImportAndSendMessage
| RequestBranchesMessage
| RequestExternalWorktreesMessage
| ImportFromBranchRequest
| ImportFromPRRequest
| ImportExternalWorktreeRequest
| ImportAllExternalWorktreesRequest
// ============================================
// VS Code API type
@@ -6,9 +6,9 @@ export interface EditorContext {
timezone?: string
}
function formatTime(timezone?: string): string[] {
function formatDate(timezone?: string): string[] {
const now = new Date()
const lines = [` Current time: ${now.toISOString()}`]
const lines = [` Today's date: ${now.toDateString()}`]
if (timezone) {
const offset = -now.getTimezoneOffset()
const sign = offset >= 0 ? "+" : "-"
@@ -24,7 +24,7 @@ function formatTime(timezone?: string): string[] {
* Returns an array of pre-formatted ` key: value` strings.
*/
export function editorContextEnvLines(ctx?: EditorContext): string[] {
const lines = formatTime(ctx?.timezone)
const lines = formatDate(ctx?.timezone)
if (ctx?.shell) {
lines.push(` Default shell: ${ctx.shell}`)
}