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:
@@ -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
|
||||
|
||||
@@ -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>→</Keycap> to expand. Browse branches, see
|
||||
session conversations with last prompt and Claude'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
|
||||
|
||||
@@ -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)
|
||||
|
||||
105
src/index.ts
105
src/index.ts
@@ -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`)}`
|
||||
: ""
|
||||
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`
|
||||
)}${activeLabel}`
|
||||
)} │ ${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)
|
||||
|
||||
Reference in New Issue
Block a user