enhance vm status with cross-repo session view

This commit is contained in:
Chris Wanstrath 2026-03-11 22:42:54 -07:00
parent aa1139c004
commit 4b178dec44
2 changed files with 109 additions and 5 deletions

View File

@ -1,6 +1,9 @@
import type { Command } from "commander" import type { Command } from "commander"
import * as vm from "../vm.ts" import * as vm from "../vm.ts"
import * as git from "../git.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts" import { spinner } from "../spinner.ts"
import { reset, dim, green, yellow, cyan, red } from "../fmt.ts"
export function register(program: Command) { export function register(program: Command) {
const vmCmd = program.command("vm").description("Manage the sandlot VM") const vmCmd = program.command("vm").description("Manage the sandlot VM")
@ -42,10 +45,61 @@ export function register(program: Command) {
vmCmd vmCmd
.command("status") .command("status")
.description("Show VM status") .description("Show VM status and all sessions across repos")
.action(async () => { .option("--json", "Output as JSON")
.action(async (opts: { json?: boolean }) => {
const s = await vm.status() 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<string, string> = { 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<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` }
const branchColors: Record<string, string> = { 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 vmCmd

View File

@ -1,5 +1,6 @@
import { join } from "path" import { join, dirname } from "path"
import { rename } from "fs/promises" import { readdir, rename } from "fs/promises"
import { homedir } from "os"
export interface Session { export interface Session {
branch: string branch: string
@ -50,3 +51,52 @@ export async function removeSession(repoRoot: string, branch: string): Promise<v
delete state.sessions[branch] delete state.sessions[branch]
await save(repoRoot, state) await save(repoRoot, state)
} }
export interface GlobalSession extends Session {
repo: string
}
/** Discover all sessions across all repos by scanning ~/.sandlot/ */
export async function loadAll(): Promise<GlobalSession[]> {
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/<name>
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
}