add status icons to list command showing session activity state

This commit is contained in:
Chris Wanstrath 2026-02-19 12:35:23 -08:00
parent 25d2de5348
commit c76340777e
3 changed files with 56 additions and 10 deletions

View File

@ -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<string, string> = { 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}`)
}
})

View File

@ -113,6 +113,21 @@ export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd).nothrow().quiet()
}
/** Check if a worktree has uncommitted changes. */
export async function isDirty(worktreePath: string): Promise<boolean> {
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<boolean> {
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<string> {
const dir = cwd ?? "."

View File

@ -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<string[]> {
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<void> {
await $`container stop ${CONTAINER_NAME}`.nothrow().quiet()