diff --git a/src/cli.ts b/src/cli.ts index 41a34ce..f180d73 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -153,33 +153,49 @@ program program .command("list") - .description("Show all active sessions") + .description("Show all active sessions (◌ idle · ◯ working · ◎ unsaved · ● saved)") .option("--json", "Output as JSON") .action(async (opts: { json?: boolean }) => { const root = await git.repoRoot() const st = await state.load(root) const sessions = Object.values(st.sessions) - if (opts.json) { - console.log(JSON.stringify(sessions, null, 2)) - return - } - if (sessions.length === 0) { - console.log("◆ No active sessions.") + if (opts.json) console.log("[]") + else console.log("◆ No active sessions.") return } + // Determine status for each session in parallel + const activeWorktrees = await vm.activeWorktrees() + const statusEntries = await Promise.all( + sessions.map(async (s): Promise<[string, string]> => { + if (activeWorktrees.includes(s.worktree)) return [s.branch, "active"] + const dirty = await git.isDirty(s.worktree) + if (dirty) return [s.branch, "dirty"] + const commits = await git.hasNewCommits(s.worktree) + return [s.branch, commits ? "saved" : "idle"] + }) + ) + const statuses = Object.fromEntries(statusEntries) + + if (opts.json) { + const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) + console.log(JSON.stringify(withStatus, null, 2)) + return + } + + const icons: Record = { idle: "◌", active: "◯", dirty: "◎", saved: "●" } const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length)) const cols = process.stdout.columns || 80 - const prefixWidth = branchWidth + 2 + const prefixWidth = branchWidth + 4 - console.log(`${"BRANCH".padEnd(branchWidth)} PROMPT`) for (const s of sessions) { const prompt = s.prompt ?? "" + const icon = icons[statuses[s.branch]] const maxPrompt = cols - prefixWidth const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt - console.log(`${s.branch.padEnd(branchWidth)} ${truncated}`) + console.log(`${icon} ${s.branch.padEnd(branchWidth)} ${truncated}`) } }) diff --git a/src/git.ts b/src/git.ts index 6eea9c6..90b2fef 100644 --- a/src/git.ts +++ b/src/git.ts @@ -113,6 +113,21 @@ export async function abortMerge(cwd: string): Promise { await $`git merge --abort`.cwd(cwd).nothrow().quiet() } +/** Check if a worktree has uncommitted changes. */ +export async function isDirty(worktreePath: string): Promise { + const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet() + if (result.exitCode !== 0) return false + return result.text().trim().length > 0 +} + +/** Check if a branch has commits beyond main. */ +export async function hasNewCommits(worktreePath: string): Promise { + const main = await mainBranch(worktreePath) + const result = await $`git -C ${worktreePath} rev-list ${main}..HEAD --count`.nothrow().quiet() + if (result.exitCode !== 0) return false + return parseInt(result.text().trim(), 10) > 0 +} + /** Detect the main branch name (main or master). */ export async function mainBranch(cwd?: string): Promise { const dir = cwd ?? "." diff --git a/src/vm.ts b/src/vm.ts index 7978a75..f6b6f2c 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -160,6 +160,21 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode } } +/** Get host paths of worktrees where claude is currently running in the container. */ +export async function activeWorktrees(): Promise { + const s = await status() + if (s !== "running") return [] + + const result = await $`container exec ${CONTAINER_NAME} bash -c ${'for pid in $(pgrep -x claude 2>/dev/null); do readlink /proc/$pid/cwd 2>/dev/null; done'}`.nothrow().quiet().text() + + const home = homedir() + return result.trim().split("\n").filter(Boolean).map(p => { + if (p.startsWith("/sandlot")) return `${home}/.sandlot${p.slice("/sandlot".length)}` + if (p.startsWith("/host")) return `${home}/dev${p.slice("/host".length)}` + return p + }) +} + /** Stop the container. */ export async function stop(): Promise { await $`container stop ${CONTAINER_NAME}`.nothrow().quiet()