feat: session status in expanded rows + idle panel

Show running/idle status inline on expanded session rows, add idle
sessions panel (toggle with 'i'), auto-switch to idle panel on
busy→idle transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-24 01:33:31 +00:00
parent c49de9865d
commit ade8034294
4 changed files with 170 additions and 19 deletions

View File

@@ -93,6 +93,7 @@ Press `→` on any project to expand it and see branches and individual sessions
Each session shows:
- **Title** — auto-generated session title
- **Status** — `● running` (green) or `◉ idle` (yellow) for active sessions
- **Last prompt** — your most recent message
- **Claude's response** — the assistant's last reply
- **Size & age** — session file size and time since last use

View File

@@ -246,7 +246,8 @@ export default function Home() {
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
Press <Keycap>&rarr;</Keycap> to expand. Browse branches, see
session conversations with last prompt and Claude&apos;s response.
Resume any session directly.
Running sessions show <span className="text-green"> running</span> or{" "}
<span className="text-yellow"> idle</span> status inline. Resume any session directly.
</p>
<TerminalWindow title="cladm — 2 selected (1 branch switch)">
<Image

View File

@@ -1,6 +1,6 @@
import { readdirSync, statSync } from "node:fs"
import { join } from "node:path"
import type { Project } from "../lib/types"
import type { Project, SessionInfo } from "../lib/types"
const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects`
const BUSY_THRESHOLD_MS = 5000
@@ -216,6 +216,80 @@ export function playDoneSound(): void {
})
}
export function getSessionStatus(projectPath: string, sessionId: string): "busy" | "idle" | null {
const sessions = sessionsByPath.get(projectPath)
if (!sessions) return null
for (const s of sessions) {
if (s.sessionFile && s.sessionFile.endsWith(`${sessionId}.jsonl`)) {
return s.busy ? "busy" : "idle"
}
}
return null
}
export function populateMockSessionStatus(project: Project): void {
if (!project.sessions || project.activeSessions === 0) return
const entries: ActiveSession[] = []
// Pick first 1-2 sessions as "active"
const activeCount = Math.min(project.activeSessions, project.sessions.length)
for (let i = 0; i < activeCount; i++) {
const s = project.sessions[i]
const isBusy = project.busySessions > 0 && i < project.busySessions
entries.push({
pid: `mock-${s.id}`,
cwd: project.path,
tty: `/dev/ttys${100 + i}`,
sessionFile: `${PROJECTS_DIR}/${project.path.replaceAll("/", "-")}/${s.id}.jsonl`,
busy: isBusy,
lastActivityMs: isBusy ? Date.now() - 1000 : Date.now() - 120_000,
})
}
sessionsByPath.set(project.path, entries)
}
export interface IdleSessionInfo {
projectPath: string
projectName: string
tty: string
idleSinceMs: number
sessionTitle: string
lastPrompt: string
}
export function getIdleSessions(projects: Project[]): IdleSessionInfo[] {
const idle: IdleSessionInfo[] = []
for (const project of projects) {
const sessions = sessionsByPath.get(project.path)
if (!sessions) continue
for (const s of sessions) {
if (s.busy) continue
if (!s.lastActivityMs) continue
// Find matching session info for title/prompt
let title = ""
let lastPrompt = ""
if (project.sessions) {
const match = project.sessions.find(
ps => s.sessionFile && s.sessionFile.endsWith(`${ps.id}.jsonl`)
)
if (match) {
title = match.title
lastPrompt = match.lastUserPrompt
}
}
idle.push({
projectPath: project.path,
projectName: project.name,
tty: s.tty,
idleSinceMs: s.lastActivityMs,
sessionTitle: title || "(session)",
lastPrompt: lastPrompt || "",
})
}
}
idle.sort((a, b) => b.idleSinceMs - a.idleSinceMs)
return idle
}
export function generateMockActiveSessions(projects: Project[]): void {
const indices = Array.from(projects.keys())
const shuffled = indices.sort(() => Math.random() - 0.5)

View File

@@ -21,7 +21,7 @@ import { discoverProjects } from "./data/history"
import { loadGitMetadata, loadBranches } from "./data/git"
import { loadSessions } from "./data/sessions"
import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock"
import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound } from "./data/monitor"
import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor"
import { launchSelections } from "./actions/launcher"
import type { Project, DisplayRow } from "./lib/types"
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
@@ -45,12 +45,14 @@ let sortedIndices: number[] = []
let displayRows: DisplayRow[] = []
let monitorInterval: ReturnType<typeof setInterval> | null = null
let prevBusySnapshot: Map<string, number> = new Map()
let bottomPanelMode: "preview" | "idle" = "preview"
// ─── UI Refs ────────────────────────────────────────────────────────
let renderer: CliRenderer
let headerText: TextRenderable
let colHeaderText: TextRenderable
let listBox: ScrollBoxRenderable
let previewBox: BoxRenderable
let previewText: TextRenderable
let footerText: TextRenderable
@@ -180,6 +182,12 @@ function fmtSessionRow(
const age = timeAgo(session.timestamp)
const size = formatSize(session.sizeBytes)
const status = getSessionStatus(project.path, session.id)
let statusTag: string
if (status === "busy") statusTag = green("● running")
else if (status === "idle") statusTag = yellow("◉ idle")
else statusTag = ""
const promptText = session.lastUserPrompt
? session.lastUserPrompt.length > 60
? session.lastUserPrompt.slice(0, 57) + "..."
@@ -191,9 +199,11 @@ function fmtSessionRow(
: session.lastAssistantMsg
: "(no text response)"
const statusSuffix = statusTag ? ` ${statusTag}` : ""
return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
size.padEnd(7)
)} ${fg(ACCENT)('"' + title + '"')}
)} ${fg(ACCENT)('"' + title + '"')}${statusSuffix}
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
}
@@ -225,12 +235,16 @@ function updateHeader() {
const modeLabel = demoMode ? " [DEMO]" : ""
const activeCount = projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0)
const busyCount = projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0)
const activeLabel = activeCount > 0
? `${green(`${busyCount} busy`)} ${yellow(`${activeCount - busyCount} idle`)}`
: ""
headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} ${String(total)} selected${branchNote} ${dim(
`sort: ${sortLabels[sortMode]} ${projects.length} projects`
)}${activeLabel}`
const idleCount = activeCount - busyCount
if (activeCount > 0) {
headerText.content = t` ${bold("cladm")}${yellow(modeLabel)}${String(total)} selected${branchNote} ${dim(
`sort: ${sortLabels[sortMode]} ${projects.length} projects`
)}${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}`
} else {
headerText.content = t` ${bold("cladm")}${yellow(modeLabel)}${String(total)} selected${branchNote} ${dim(
`sort: ${sortLabels[sortMode]}${projects.length} projects`
)}`
}
}
function updateColumnHeaders() {
@@ -238,9 +252,49 @@ function updateColumnHeaders() {
colHeaderText.content = t` ${dim(cols)}`
}
function fmtIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string }) {
const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6)
const name = (s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName).padEnd(22)
const title = s.sessionTitle.length > 40 ? s.sessionTitle.slice(0, 37) + "..." : s.sessionTitle
return t` ${yellow("◉")} ${dim(elapsed)}${name}${dim('"' + title + '"')}`
}
function updateIdlePanel() {
const idle = getIdleSessions(projects)
const n = idle.length
previewBox.title = ` Idle Sessions (${n}) `
if (n === 0) {
previewText.content = t`${dim(" No idle sessions")}`
return
}
const show = idle.slice(0, 5)
const r0 = show[0] ? fmtIdleRow(show[0]) : t``
const r1 = show[1] ? fmtIdleRow(show[1]) : null
const r2 = show[2] ? fmtIdleRow(show[2]) : null
const r3 = show[3] ? fmtIdleRow(show[3]) : null
const r4 = show[4] ? fmtIdleRow(show[4]) : null
const more = n > 5 ? t`
${dim(`+${n - 5} more`)}` : t``
previewText.content = t` ${dim("TIME".padEnd(6))}${dim("PROJECT".padEnd(22))}${dim("SESSION")}
${r0}${r1 ? t`
${r1}` : t``}${r2 ? t`
${r2}` : t``}${r3 ? t`
${r3}` : t``}${r4 ? t`
${r4}` : t``}${more}`
}
function updateBottomPanel() {
if (bottomPanelMode === "idle") {
updateIdlePanel()
} else {
previewBox.title = " Preview "
updatePreview()
}
}
function updateFooter() {
footerText.content = t` ${dim(
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ a all │ n none │ s sort │ enter launch │ q quit"
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter launch │ q quit"
)}`
}
@@ -265,7 +319,9 @@ function updatePreview() {
)} ${project.tags || "-"}`
} else if (row.type === "session" && project.sessions) {
const s = project.sessions[row.sessionIndex!]
previewText.content = t` ${bold("Session:")} ${s.title}
const sStatus = getSessionStatus(project.path, s.id)
const sLabel = sStatus === "busy" ? green(" ● running") : sStatus === "idle" ? yellow(" ◉ idle") : ""
previewText.content = t` ${bold("Session:")} ${s.title}${sLabel}
${dim(timeAgo(s.timestamp))} · ${dim(formatSize(s.sizeBytes))} · ${magenta(s.branch || "-")}
${dim("Last prompt:")} ${s.lastUserPrompt || dim("(no text)")}
${dim("Claude:")} ${s.lastAssistantMsg || dim("(no text response)")}`
@@ -315,8 +371,9 @@ function rebuildList() {
content = fmtNewSessionRow(row.projectIndex, isSel)
}
const isActive = row.type === "project" && project.activeSessions > 0
const bgColor = isCursor ? CURSOR_BG : isActive ? ACTIVE_BG : undefined
const isActiveProject = row.type === "project" && project.activeSessions > 0
const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null
const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined
if (bgColor) {
listBox.add(
@@ -365,7 +422,7 @@ function ensureCursorVisible() {
function updateAll() {
updateHeader()
rebuildList()
updatePreview()
updateBottomPanel()
}
// ─── Keyboard ───────────────────────────────────────────────────────
@@ -476,6 +533,10 @@ function handleKeypress(key: KeyEvent) {
selectedBranches.clear()
break
case "i":
bottomPanelMode = bottomPanelMode === "preview" ? "idle" : "preview"
break
case "s":
sortMode = (sortMode + 1) % sortLabels.length
applySortMode()
@@ -521,6 +582,7 @@ async function expandProject(projectIndex: number) {
if (!project.branches) {
project.branches = generateMockBranches(project.path)
}
populateMockSessionStatus(project)
} else {
const loads: Promise<void>[] = []
if (!project.sessions) {
@@ -618,7 +680,7 @@ async function main() {
viewportCulling: true,
})
const previewBox = new BoxRenderable(renderer, {
previewBox = new BoxRenderable(renderer, {
height: 7,
flexShrink: 0,
width: "100%",
@@ -655,7 +717,7 @@ async function main() {
updateHeader()
updateColumnHeaders()
rebuildList()
updatePreview()
updateBottomPanel()
updateFooter()
renderer.keyInput.on("keypress", handleKeypress)
@@ -664,6 +726,13 @@ async function main() {
if (demoMode) {
generateMockActiveSessions(projects)
generateMockBusySessions(projects)
for (const p of projects) {
if (p.activeSessions > 0 && !p.sessions) {
p.sessions = generateMockSessions(p.path)
p.sessionCount = p.sessions.length
}
populateMockSessionStatus(p)
}
prevBusySnapshot = snapshotBusy(projects)
updateAll()
} else {
@@ -680,14 +749,20 @@ async function main() {
generateMockBusySessions(projects)
const transitioned = checkTransitions(projects, prevBusySnapshot)
prevBusySnapshot = snapshotBusy(projects)
if (transitioned.length > 0) playDoneSound()
if (transitioned.length > 0) {
playDoneSound()
bottomPanelMode = "idle"
}
updateAll()
} else {
const sessions = await detectActiveSessions()
const changed = updateProjectSessions(projects, sessions)
const transitioned = checkTransitions(projects, prevBusySnapshot)
prevBusySnapshot = snapshotBusy(projects)
if (transitioned.length > 0) playDoneSound()
if (transitioned.length > 0) {
playDoneSound()
bottomPanelMode = "idle"
}
if (changed) updateAll()
}
}, 5000)