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:
|
Each session shows:
|
||||||
- **Title** — auto-generated session title
|
- **Title** — auto-generated session title
|
||||||
|
- **Status** — `● running` (green) or `◉ idle` (yellow) for active sessions
|
||||||
- **Last prompt** — your most recent message
|
- **Last prompt** — your most recent message
|
||||||
- **Claude's response** — the assistant's last reply
|
- **Claude's response** — the assistant's last reply
|
||||||
- **Size & age** — session file size and time since last use
|
- **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">
|
<p className="font-[family-name:var(--font-mono)] text-dim text-xs text-center mb-6">
|
||||||
Press <Keycap>→</Keycap> to expand. Browse branches, see
|
Press <Keycap>→</Keycap> to expand. Browse branches, see
|
||||||
session conversations with last prompt and Claude's response.
|
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>
|
</p>
|
||||||
<TerminalWindow title="cladm — 2 selected (1 branch switch)">
|
<TerminalWindow title="cladm — 2 selected (1 branch switch)">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readdirSync, statSync } from "node:fs"
|
import { readdirSync, statSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
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 PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects`
|
||||||
const BUSY_THRESHOLD_MS = 5000
|
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 {
|
export function generateMockActiveSessions(projects: Project[]): void {
|
||||||
const indices = Array.from(projects.keys())
|
const indices = Array.from(projects.keys())
|
||||||
const shuffled = indices.sort(() => Math.random() - 0.5)
|
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 { loadGitMetadata, loadBranches } from "./data/git"
|
||||||
import { loadSessions } from "./data/sessions"
|
import { loadSessions } from "./data/sessions"
|
||||||
import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock"
|
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 { launchSelections } from "./actions/launcher"
|
||||||
import type { Project, DisplayRow } from "./lib/types"
|
import type { Project, DisplayRow } from "./lib/types"
|
||||||
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
||||||
@@ -45,12 +45,14 @@ let sortedIndices: number[] = []
|
|||||||
let displayRows: DisplayRow[] = []
|
let displayRows: DisplayRow[] = []
|
||||||
let monitorInterval: ReturnType<typeof setInterval> | null = null
|
let monitorInterval: ReturnType<typeof setInterval> | null = null
|
||||||
let prevBusySnapshot: Map<string, number> = new Map()
|
let prevBusySnapshot: Map<string, number> = new Map()
|
||||||
|
let bottomPanelMode: "preview" | "idle" = "preview"
|
||||||
|
|
||||||
// ─── UI Refs ────────────────────────────────────────────────────────
|
// ─── UI Refs ────────────────────────────────────────────────────────
|
||||||
let renderer: CliRenderer
|
let renderer: CliRenderer
|
||||||
let headerText: TextRenderable
|
let headerText: TextRenderable
|
||||||
let colHeaderText: TextRenderable
|
let colHeaderText: TextRenderable
|
||||||
let listBox: ScrollBoxRenderable
|
let listBox: ScrollBoxRenderable
|
||||||
|
let previewBox: BoxRenderable
|
||||||
let previewText: TextRenderable
|
let previewText: TextRenderable
|
||||||
let footerText: TextRenderable
|
let footerText: TextRenderable
|
||||||
|
|
||||||
@@ -180,6 +182,12 @@ function fmtSessionRow(
|
|||||||
const age = timeAgo(session.timestamp)
|
const age = timeAgo(session.timestamp)
|
||||||
const size = formatSize(session.sizeBytes)
|
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
|
const promptText = session.lastUserPrompt
|
||||||
? session.lastUserPrompt.length > 60
|
? session.lastUserPrompt.length > 60
|
||||||
? session.lastUserPrompt.slice(0, 57) + "..."
|
? session.lastUserPrompt.slice(0, 57) + "..."
|
||||||
@@ -191,9 +199,11 @@ function fmtSessionRow(
|
|||||||
: session.lastAssistantMsg
|
: session.lastAssistantMsg
|
||||||
: "(no text response)"
|
: "(no text response)"
|
||||||
|
|
||||||
|
const statusSuffix = statusTag ? ` ${statusTag}` : ""
|
||||||
|
|
||||||
return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
|
||||||
size.padEnd(7)
|
size.padEnd(7)
|
||||||
)} ${fg(ACCENT)('"' + title + '"')}
|
)} ${fg(ACCENT)('"' + title + '"')}${statusSuffix}
|
||||||
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
|
||||||
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
|
||||||
}
|
}
|
||||||
@@ -225,12 +235,16 @@ function updateHeader() {
|
|||||||
const modeLabel = demoMode ? " [DEMO]" : ""
|
const modeLabel = demoMode ? " [DEMO]" : ""
|
||||||
const activeCount = projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0)
|
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 busyCount = projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0)
|
||||||
const activeLabel = activeCount > 0
|
const idleCount = activeCount - busyCount
|
||||||
? ` │ ${green(`${busyCount} busy`)} ${yellow(`${activeCount - busyCount} idle`)}`
|
if (activeCount > 0) {
|
||||||
: ""
|
|
||||||
headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim(
|
headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim(
|
||||||
`sort: ${sortLabels[sortMode]} │ ${projects.length} projects`
|
`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() {
|
function updateColumnHeaders() {
|
||||||
@@ -238,9 +252,49 @@ function updateColumnHeaders() {
|
|||||||
colHeaderText.content = t` ${dim(cols)}`
|
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() {
|
function updateFooter() {
|
||||||
footerText.content = t` ${dim(
|
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 || "-"}`
|
)} ${project.tags || "-"}`
|
||||||
} else if (row.type === "session" && project.sessions) {
|
} else if (row.type === "session" && project.sessions) {
|
||||||
const s = project.sessions[row.sessionIndex!]
|
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(timeAgo(s.timestamp))} · ${dim(formatSize(s.sizeBytes))} · ${magenta(s.branch || "-")}
|
||||||
${dim("Last prompt:")} ${s.lastUserPrompt || dim("(no text)")}
|
${dim("Last prompt:")} ${s.lastUserPrompt || dim("(no text)")}
|
||||||
${dim("Claude:")} ${s.lastAssistantMsg || dim("(no text response)")}`
|
${dim("Claude:")} ${s.lastAssistantMsg || dim("(no text response)")}`
|
||||||
@@ -315,8 +371,9 @@ function rebuildList() {
|
|||||||
content = fmtNewSessionRow(row.projectIndex, isSel)
|
content = fmtNewSessionRow(row.projectIndex, isSel)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActive = row.type === "project" && project.activeSessions > 0
|
const isActiveProject = row.type === "project" && project.activeSessions > 0
|
||||||
const bgColor = isCursor ? CURSOR_BG : isActive ? ACTIVE_BG : undefined
|
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) {
|
if (bgColor) {
|
||||||
listBox.add(
|
listBox.add(
|
||||||
@@ -365,7 +422,7 @@ function ensureCursorVisible() {
|
|||||||
function updateAll() {
|
function updateAll() {
|
||||||
updateHeader()
|
updateHeader()
|
||||||
rebuildList()
|
rebuildList()
|
||||||
updatePreview()
|
updateBottomPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Keyboard ───────────────────────────────────────────────────────
|
// ─── Keyboard ───────────────────────────────────────────────────────
|
||||||
@@ -476,6 +533,10 @@ function handleKeypress(key: KeyEvent) {
|
|||||||
selectedBranches.clear()
|
selectedBranches.clear()
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case "i":
|
||||||
|
bottomPanelMode = bottomPanelMode === "preview" ? "idle" : "preview"
|
||||||
|
break
|
||||||
|
|
||||||
case "s":
|
case "s":
|
||||||
sortMode = (sortMode + 1) % sortLabels.length
|
sortMode = (sortMode + 1) % sortLabels.length
|
||||||
applySortMode()
|
applySortMode()
|
||||||
@@ -521,6 +582,7 @@ async function expandProject(projectIndex: number) {
|
|||||||
if (!project.branches) {
|
if (!project.branches) {
|
||||||
project.branches = generateMockBranches(project.path)
|
project.branches = generateMockBranches(project.path)
|
||||||
}
|
}
|
||||||
|
populateMockSessionStatus(project)
|
||||||
} else {
|
} else {
|
||||||
const loads: Promise<void>[] = []
|
const loads: Promise<void>[] = []
|
||||||
if (!project.sessions) {
|
if (!project.sessions) {
|
||||||
@@ -618,7 +680,7 @@ async function main() {
|
|||||||
viewportCulling: true,
|
viewportCulling: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewBox = new BoxRenderable(renderer, {
|
previewBox = new BoxRenderable(renderer, {
|
||||||
height: 7,
|
height: 7,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -655,7 +717,7 @@ async function main() {
|
|||||||
updateHeader()
|
updateHeader()
|
||||||
updateColumnHeaders()
|
updateColumnHeaders()
|
||||||
rebuildList()
|
rebuildList()
|
||||||
updatePreview()
|
updateBottomPanel()
|
||||||
updateFooter()
|
updateFooter()
|
||||||
|
|
||||||
renderer.keyInput.on("keypress", handleKeypress)
|
renderer.keyInput.on("keypress", handleKeypress)
|
||||||
@@ -664,6 +726,13 @@ async function main() {
|
|||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
generateMockActiveSessions(projects)
|
generateMockActiveSessions(projects)
|
||||||
generateMockBusySessions(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)
|
prevBusySnapshot = snapshotBusy(projects)
|
||||||
updateAll()
|
updateAll()
|
||||||
} else {
|
} else {
|
||||||
@@ -680,14 +749,20 @@ async function main() {
|
|||||||
generateMockBusySessions(projects)
|
generateMockBusySessions(projects)
|
||||||
const transitioned = checkTransitions(projects, prevBusySnapshot)
|
const transitioned = checkTransitions(projects, prevBusySnapshot)
|
||||||
prevBusySnapshot = snapshotBusy(projects)
|
prevBusySnapshot = snapshotBusy(projects)
|
||||||
if (transitioned.length > 0) playDoneSound()
|
if (transitioned.length > 0) {
|
||||||
|
playDoneSound()
|
||||||
|
bottomPanelMode = "idle"
|
||||||
|
}
|
||||||
updateAll()
|
updateAll()
|
||||||
} else {
|
} else {
|
||||||
const sessions = await detectActiveSessions()
|
const sessions = await detectActiveSessions()
|
||||||
const changed = updateProjectSessions(projects, sessions)
|
const changed = updateProjectSessions(projects, sessions)
|
||||||
const transitioned = checkTransitions(projects, prevBusySnapshot)
|
const transitioned = checkTransitions(projects, prevBusySnapshot)
|
||||||
prevBusySnapshot = snapshotBusy(projects)
|
prevBusySnapshot = snapshotBusy(projects)
|
||||||
if (transitioned.length > 0) playDoneSound()
|
if (transitioned.length > 0) {
|
||||||
|
playDoneSound()
|
||||||
|
bottomPanelMode = "idle"
|
||||||
|
}
|
||||||
if (changed) updateAll()
|
if (changed) updateAll()
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
|
|||||||
Reference in New Issue
Block a user