From 4b178dec4441f08e0c5ae11d192ada558b045efb Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 11 Mar 2026 22:42:54 -0700 Subject: [PATCH] enhance vm status with cross-repo session view --- src/commands/vm.ts | 60 +++++++++++++++++++++++++++++++++++++++++++--- src/state.ts | 54 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/commands/vm.ts b/src/commands/vm.ts index f14fef7..be1f29a 100644 --- a/src/commands/vm.ts +++ b/src/commands/vm.ts @@ -1,6 +1,9 @@ import type { Command } from "commander" import * as vm from "../vm.ts" +import * as git from "../git.ts" +import * as state from "../state.ts" import { spinner } from "../spinner.ts" +import { reset, dim, green, yellow, cyan, red } from "../fmt.ts" export function register(program: Command) { const vmCmd = program.command("vm").description("Manage the sandlot VM") @@ -42,10 +45,61 @@ export function register(program: Command) { vmCmd .command("status") - .description("Show VM status") - .action(async () => { + .description("Show VM status and all sessions across repos") + .option("--json", "Output as JSON") + .action(async (opts: { json?: boolean }) => { const s = await vm.status() - console.log(s) + const sessions = await state.loadAll() + + if (opts.json) { + console.log(JSON.stringify({ vm: s, sessions }, null, 2)) + return + } + + const statusColors: Record = { running: green, stopped: yellow, missing: red } + console.log(`${dim}VM:${reset} ${statusColors[s] ?? ""}${s}${reset}`) + + if (sessions.length === 0) { + console.log(`\n${dim}No active sessions.${reset}`) + return + } + + // Determine status for each session in parallel + const statusEntries = await Promise.all( + sessions.map(async (sess): Promise<[string, string]> => { + const key = `${sess.repo}/${sess.branch}` + try { + if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"] + if (await git.isDirty(sess.worktree)) return [key, "dirty"] + if (await git.hasNewCommits(sess.worktree)) return [key, "saved"] + } catch {} + return [key, "idle"] + }) + ) + const statuses = Object.fromEntries(statusEntries) + + const icons: Record = { idle: `${dim}◯${reset}`, active: `${cyan}◎${reset}`, dirty: `${yellow}◐${reset}`, saved: `${green}●${reset}` } + const branchColors: Record = { idle: dim, active: cyan, dirty: yellow, saved: green } + + const repoWidth = Math.max(4, ...sessions.map(sess => sess.repo.length)) + const branchWidth = Math.max(6, ...sessions.map(sess => sess.branch.length)) + const cols = process.stdout.columns || 80 + const prefixWidth = repoWidth + branchWidth + 6 + + console.log(`\n ${dim}${"REPO".padEnd(repoWidth)} ${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) + + for (const sess of sessions) { + const prompt = (sess.prompt ?? "").split("\n")[0] + const key = `${sess.repo}/${sess.branch}` + const status = statuses[key] + const icon = icons[status] + const bc = branchColors[status] + const maxPrompt = cols - prefixWidth + const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt + console.log(`${icon} ${dim}${sess.repo.padEnd(repoWidth)}${reset} ${bc}${sess.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) + } + + console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`) }) vmCmd diff --git a/src/state.ts b/src/state.ts index f5cdede..f4d0015 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,5 +1,6 @@ -import { join } from "path" -import { rename } from "fs/promises" +import { join, dirname } from "path" +import { readdir, rename } from "fs/promises" +import { homedir } from "os" export interface Session { branch: string @@ -50,3 +51,52 @@ export async function removeSession(repoRoot: string, branch: string): Promise { + const sandlotDir = join(homedir(), ".sandlot") + const all: GlobalSession[] = [] + + let repoDirs + try { + repoDirs = await readdir(sandlotDir, { withFileTypes: true }) + } catch { + return [] + } + + for (const entry of repoDirs) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue + const repoDir = join(sandlotDir, entry.name) + + // Find the main repo root from a worktree's .git pointer + let repoRoot: string | null = null + const branchEntries = await readdir(repoDir, { withFileTypes: true }).catch(() => []) + + for (const be of branchEntries) { + if (!be.isDirectory() || be.name.startsWith(".")) continue + const dotGit = await Bun.file(join(repoDir, be.name, ".git")).text().catch(() => "") + const m = dotGit.match(/^gitdir:\s*(.+)/m) + if (m) { + // gitdir: /path/to/repo/.git/worktrees/ + const mainGit = m[1].trim().replace(/\/worktrees\/[^/]+$/, "") + repoRoot = dirname(mainGit) + break + } + } + + if (repoRoot) { + try { + const st = await load(repoRoot) + for (const session of Object.values(st.sessions)) { + all.push({ ...session, repo: entry.name }) + } + } catch {} + } + } + + return all +}