import { homedir } from "os" import * as git from "../git.ts" import * as vm from "../vm.ts" import * as state from "../state.ts" import { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" // ── Shared rendering ───────────────────────────────────────────────── const styleDefs: [string, string, string][] = [ ["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"], ["saved", green, "●"], ["review", magenta, "⊛"], ] const styles = Object.fromEntries( styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }]) ) function renderSessions( sessions: { branch: string; prompt?: string }[], statuses: Record, keyFn: (s: { branch: string }) => string = s => s.branch, ) { const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length)) const cols = process.stdout.columns || 80 const prefixWidth = branchWidth + 4 console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) for (const s of sessions) { const prompt = (s.prompt ?? "").split("\n")[0] const status = statuses[keyFn(s)] ?? "idle" const { icon, color: bc } = styles[status] const maxPrompt = cols - prefixWidth const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) } } function renderLegend() { console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`) } // ── Shared logic ───────────────────────────────────────────────────── async function resolveStatus( s: { branch: string; worktree: string; in_review?: boolean }, key: string, vmRunning: boolean, ): Promise<{ key: string; status: string; staleReview: boolean }> { let staleReview = false if (vmRunning) { const active = await vm.isClaudeActive(s.worktree, s.branch) if (active && s.in_review) return { key, status: "review", staleReview: false } staleReview = !active && !!s.in_review if (active) return { key, status: "active", staleReview: false } } const dirty = await git.isDirty(s.worktree) if (dirty) return { key, status: "dirty", staleReview } const commits = await git.hasNewCommits(s.worktree) return { key, status: commits ? "saved" : "idle", staleReview } } async function clearStaleReviews(staleReviews: { repoRoot: string; branch: string }[]) { if (staleReviews.length === 0) return const byRepo = Map.groupBy(staleReviews, r => r.repoRoot) for (const [repoRoot, reviews] of byRepo) { const fresh = await state.load(repoRoot) for (const { branch } of reviews) { if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false } await state.save(repoRoot, fresh).catch(() => {}) } } async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) { if (!vmRunning) return const needsPrompt = sessions.filter(s => !s.prompt) if (needsPrompt.length === 0) return try { const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null") if (result.exitCode === 0 && result.stdout) { const entries = result.stdout.split("\n").filter(Boolean).map(line => { try { return JSON.parse(line) } catch { return null } }).filter(Boolean) for (const s of needsPrompt) { const cPath = vm.containerPath(s.worktree) const match = entries.find((e: any) => e.project === cPath) if (match?.display) s.prompt = match.display } } } catch {} } // ── Commands ───────────────────────────────────────────────────────── export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) { if (opts.add) return actionAdd(opts.add) if (opts.remove) return actionRemove(opts.remove) if (opts.all) return actionAll(opts) const root = await git.repoRoot() const st = await state.load(root) const sessions = Object.values(st.sessions) const vmRunning = (await vm.status()) === "running" await backfillPrompts(sessions, vmRunning) if (sessions.length === 0 && !opts.json) { console.log("◆ No active sessions.") if (!vmRunning) { console.log(`\n${red}VM is not running.${reset}`) } return } const results = await Promise.all( sessions.map(s => resolveStatus(s, s.branch, vmRunning)) ) const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) const staleReviews: { repoRoot: string; branch: string }[] = [] for (let i = 0; i < results.length; i++) { if (results[i].staleReview) { sessions[i].in_review = false staleReviews.push({ repoRoot: root, branch: sessions[i].branch }) } } await clearStaleReviews(staleReviews) if (opts.json) { const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) console.log(JSON.stringify(withStatus, null, 2)) return } renderSessions(sessions, statuses) renderLegend() if (!vmRunning) { console.log(`\n${red}VM is not running.${reset}`) } } async function actionAdd(dir: string) { const registered = await state.scanAndRegister(dir) if (registered.length === 0) { console.log(`No sandlot projects found under ${dir}`) } else { for (const p of registered) { console.log(`${green}+${reset} ${p}`) } console.log(`\nRegistered ${registered.length} project${registered.length === 1 ? "" : "s"}.`) } } async function actionRemove(dir: string) { const removed = await state.unregisterProject(dir) if (removed) { console.log(`${red}-${reset} ${dir}`) } else { die(`Project not found in registry: ${dir}`) } } async function actionAll(opts: { json?: boolean }) { const sessions = await state.loadAll() if (opts.json) { console.log(JSON.stringify(sessions, null, 2)) return } if (sessions.length === 0) { console.log("◆ No active sessions across any project.") return } const vmRunning = (await vm.status()) === "running" await backfillPrompts(sessions, vmRunning) const results = await Promise.all( sessions.map(s => resolveStatus(s, `${s.repo}/${s.branch}`, vmRunning)) ) const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) const staleReviews: { repoRoot: string; branch: string }[] = [] for (let i = 0; i < results.length; i++) { if (results[i].staleReview) { sessions[i].in_review = false staleReviews.push({ repoRoot: sessions[i].repoRoot, branch: sessions[i].branch }) } } await clearStaleReviews(staleReviews) const byRepo = Map.groupBy(sessions, s => s.repo) for (const [repo, repoSessions] of byRepo) { console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`) renderSessions(repoSessions, statuses, s => `${repo}/${s.branch}`) } renderLegend() if (!vmRunning) { console.log(`\n${red}VM is not running.${reset}`) } }