UNPKG

@mariozechner/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

314 lines 11.2 kB
import { execFile, spawnSync } from "child_process"; import { existsSync, readFileSync, statSync, unwatchFile, watch, watchFile } from "fs"; import { dirname, join, resolve } from "path"; /** * Find git metadata paths by walking up from cwd. * Handles both regular git repos (.git is a directory) and worktrees (.git is a file). */ function findGitPaths(cwd) { let dir = cwd; while (true) { const gitPath = join(dir, ".git"); if (existsSync(gitPath)) { try { const stat = statSync(gitPath); if (stat.isFile()) { const content = readFileSync(gitPath, "utf8").trim(); if (content.startsWith("gitdir: ")) { const gitDir = resolve(dir, content.slice(8).trim()); const headPath = join(gitDir, "HEAD"); if (!existsSync(headPath)) return null; const commonDirPath = join(gitDir, "commondir"); const commonGitDir = existsSync(commonDirPath) ? resolve(gitDir, readFileSync(commonDirPath, "utf8").trim()) : gitDir; return { repoDir: dir, commonGitDir, headPath }; } } else if (stat.isDirectory()) { const headPath = join(gitPath, "HEAD"); if (!existsSync(headPath)) return null; return { repoDir: dir, commonGitDir: gitPath, headPath }; } } catch { return null; } } const parent = dirname(dir); if (parent === dir) return null; dir = parent; } } /** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */ function resolveBranchWithGitSync(repoDir) { const result = spawnSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); const branch = result.status === 0 ? result.stdout.trim() : ""; return branch || null; } /** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */ function resolveBranchWithGitAsync(repoDir) { return new Promise((resolvePromise) => { execFile("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repoDir, encoding: "utf8", }, (error, stdout) => { if (error) { resolvePromise(null); return; } const branch = stdout.trim(); resolvePromise(branch || null); }); }); } /** * Provides git branch and extension statuses - data not otherwise accessible to extensions. * Token stats, model info available via ctx.sessionManager and ctx.model. */ export class FooterDataProvider { cwd; static WATCH_DEBOUNCE_MS = 500; extensionStatuses = new Map(); cachedBranch = undefined; gitPaths = undefined; headWatcher = null; reftableWatcher = null; reftableTablesListWatcher = null; reftableTablesListPath = null; branchChangeCallbacks = new Set(); availableProviderCount = 0; refreshTimer = null; refreshInFlight = false; refreshPending = false; disposed = false; constructor(cwd) { this.cwd = cwd; this.gitPaths = findGitPaths(cwd); this.setupGitWatcher(); } /** Current git branch, null if not in repo, "detached" if detached HEAD */ getGitBranch() { if (this.cachedBranch === undefined) { this.cachedBranch = this.resolveGitBranchSync(); } return this.cachedBranch; } /** Extension status texts set via ctx.ui.setStatus() */ getExtensionStatuses() { return this.extensionStatuses; } /** Subscribe to git branch changes. Returns unsubscribe function. */ onBranchChange(callback) { this.branchChangeCallbacks.add(callback); return () => this.branchChangeCallbacks.delete(callback); } /** Internal: set extension status */ setExtensionStatus(key, text) { if (text === undefined) { this.extensionStatuses.delete(key); } else { this.extensionStatuses.set(key, text); } } /** Internal: clear extension statuses */ clearExtensionStatuses() { this.extensionStatuses.clear(); } /** Number of unique providers with available models (for footer display) */ getAvailableProviderCount() { return this.availableProviderCount; } /** Internal: update available provider count */ setAvailableProviderCount(count) { this.availableProviderCount = count; } setCwd(cwd) { if (this.cwd === cwd) { return; } this.cwd = cwd; if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } if (this.headWatcher) { this.headWatcher.close(); this.headWatcher = null; } if (this.reftableWatcher) { this.reftableWatcher.close(); this.reftableWatcher = null; } if (this.reftableTablesListWatcher) { this.reftableTablesListWatcher.close(); this.reftableTablesListWatcher = null; } if (this.reftableTablesListPath) { unwatchFile(this.reftableTablesListPath); this.reftableTablesListPath = null; } this.cachedBranch = undefined; this.gitPaths = findGitPaths(cwd); this.setupGitWatcher(); this.notifyBranchChange(); } /** Internal: cleanup */ dispose() { this.disposed = true; if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } if (this.headWatcher) { this.headWatcher.close(); this.headWatcher = null; } if (this.reftableWatcher) { this.reftableWatcher.close(); this.reftableWatcher = null; } if (this.reftableTablesListWatcher) { this.reftableTablesListWatcher.close(); this.reftableTablesListWatcher = null; } if (this.reftableTablesListPath) { unwatchFile(this.reftableTablesListPath); this.reftableTablesListPath = null; } this.branchChangeCallbacks.clear(); } notifyBranchChange() { for (const cb of this.branchChangeCallbacks) cb(); } scheduleRefresh() { if (this.disposed || this.refreshTimer) return; if (this.refreshInFlight) { this.refreshPending = true; return; } this.refreshTimer = setTimeout(() => { this.refreshTimer = null; void this.refreshGitBranchAsync(); }, FooterDataProvider.WATCH_DEBOUNCE_MS); } async refreshGitBranchAsync() { if (this.disposed) return; if (this.refreshInFlight) { this.refreshPending = true; return; } this.refreshInFlight = true; try { const nextBranch = await this.resolveGitBranchAsync(); if (this.disposed) return; if (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) { this.cachedBranch = nextBranch; this.notifyBranchChange(); return; } this.cachedBranch = nextBranch; } finally { this.refreshInFlight = false; if (this.refreshPending && !this.disposed) { this.refreshPending = false; this.scheduleRefresh(); } } } resolveGitBranchSync() { try { if (!this.gitPaths) return null; const content = readFileSync(this.gitPaths.headPath, "utf8").trim(); if (content.startsWith("ref: refs/heads/")) { const branch = content.slice(16); return branch === ".invalid" ? (resolveBranchWithGitSync(this.gitPaths.repoDir) ?? "detached") : branch; } return "detached"; } catch { return null; } } async resolveGitBranchAsync() { try { if (!this.gitPaths) return null; const content = readFileSync(this.gitPaths.headPath, "utf8").trim(); if (content.startsWith("ref: refs/heads/")) { const branch = content.slice(16); return branch === ".invalid" ? ((await resolveBranchWithGitAsync(this.gitPaths.repoDir)) ?? "detached") : branch; } return "detached"; } catch { return null; } } setupGitWatcher() { if (!this.gitPaths) return; // Watch the directory containing HEAD, not HEAD itself. // Git uses atomic writes (write temp, rename over HEAD), which changes the inode. // fs.watch on a file stops working after the inode changes. try { this.headWatcher = watch(dirname(this.gitPaths.headPath), (_eventType, filename) => { if (!filename || filename.toString() === "HEAD") { this.scheduleRefresh(); } }); } catch { // Silently fail if we can't watch } // In reftable repos, branch switches update files in the reftable directory // instead of HEAD. Watch it separately so the footer picks up those changes. const reftableDir = join(this.gitPaths.commonGitDir, "reftable"); if (existsSync(reftableDir)) { try { this.reftableWatcher = watch(reftableDir, () => { this.scheduleRefresh(); }); } catch { // Silently fail if we can't watch } const tablesListPath = join(reftableDir, "tables.list"); if (existsSync(tablesListPath)) { this.reftableTablesListPath = tablesListPath; try { this.reftableTablesListWatcher = watch(tablesListPath, () => { this.scheduleRefresh(); }); } catch { // Silently fail if we can't watch } watchFile(tablesListPath, { interval: 250 }, (current, previous) => { if (current.mtimeMs !== previous.mtimeMs || current.ctimeMs !== previous.ctimeMs || current.size !== previous.size) { this.scheduleRefresh(); } }); } } } } //# sourceMappingURL=footer-data-provider.js.map