diff --git a/README.md b/README.md index a15f7bb..d6a6541 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

TUI launcher for Claude Code sessions

- Browse all your projects, see git status at a glance, expand into sessions and branches, then launch everything in parallel Terminal windows. + Browse all your projects, see git status at a glance, monitor active sessions in real time, get notified when Claude finishes, and launch everything in parallel Terminal windows.

--- @@ -46,6 +46,20 @@ cladm --demo # launch with mock data (try it out without any history) cladm reads `~/.claude/history.jsonl` to discover every project you've used with Claude Code, then enriches each one with live git metadata. The result is a fast, keyboard-driven picker that shows you everything at a glance. +## Live activity monitoring + +cladm detects running Claude Code sessions and shows their real-time status: + +| Indicator | Meaning | +|-----------|---------| +| `●` (green) | **Busy** — Claude is actively processing | +| `◉ 3m` (yellow) | **Idle** — Claude finished 3 min ago, waiting for input | +| `○` (dim) | No active session | + +**How it works:** cladm monitors JSONL file modification times in `~/.claude/projects/`. Sessions writing within 5 seconds are considered busy; otherwise idle. The elapsed time since the last response is shown next to idle indicators. + +**Sound notification:** When any session transitions from busy → idle, cladm plays a system sound (`Glass.aiff`) so you never miss a completed response — even when working across multiple projects. + ## Screenshots ### Project list @@ -96,6 +110,8 @@ Select a branch to launch Claude with a prompt to switch to that branch. Select | `a` | Select all | | `n` | Deselect all | | `s` | Cycle sort mode (recent → name → commit → sessions) | +| `f` | Open project folder in Finder | +| `g` | Go to active session (focus Terminal) | | `Enter` | Launch selected in Terminal.app | | `PageUp` `PageDown` | Jump 15 rows | | `Home` `End` | Jump to top/bottom | diff --git a/site/app/page.tsx b/site/app/page.tsx index e7b13ab..a14a5bd 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -14,6 +14,8 @@ import { LinkedinIcon, MailIcon, SpaceInvadersIcon, + EyeIcon, + BellIcon, } from "raster-react"; function PixelDivider() { @@ -139,9 +141,9 @@ export default function Home() {

- Browse all your projects. See git status at a glance. Expand - into sessions and branches. Launch everything in parallel - Terminal windows. + Browse all your projects. See git status at a glance. Monitor + which sessions are busy or idle in real time. Get notified when + Claude finishes. Launch everything in parallel Terminal windows.

{/* Install command */} @@ -261,6 +263,52 @@ export default function Home() { + {/* ══════ LIVE MONITORING ══════ */} +
+

+ // LIVE SESSION MONITORING +

+ +
+
+

+ cladm detects active Claude Code sessions and shows their real-time status. + When Claude finishes responding, a sound notification plays so you never miss it. +

+ +
+
+ + Busy + — Claude is actively processing +
+
+ + 3m + Idle + — Claude finished 3 min ago, waiting for input +
+
+ + No session + — No active Claude process +
+
+ +
+

+ Detection works by monitoring JSONL file modification times in{" "} + ~/.claude/projects/. Sessions + writing within 5 seconds are busy; otherwise idle. Header shows + total busy/idle count across all projects. +

+
+
+
+
+ + + {/* ══════ FEATURES ══════ */}

@@ -288,6 +336,16 @@ export default function Home() { title="BRANCH SWITCHING" desc="Select a non-current branch to launch Claude with an auto-prompt to switch and stash." /> + } + title="LIVE ACTIVITY" + desc="See which sessions are busy or idle in real time. Green dot = Claude is working. Yellow dot + elapsed time = waiting for input." + /> + } + title="DONE SOUND" + desc="Plays a sound when Claude finishes responding. Never miss a completed task — even across multiple sessions." + /> } title="PARALLEL LAUNCH" @@ -320,6 +378,8 @@ export default function Home() { ["a", "Select all"], ["n", "Deselect all"], ["s", "Cycle sort mode"], + ["f", "Open folder in Finder"], + ["g", "Go to active session"], ["Enter", "Launch selected"], ["PgUp PgDn", "Jump 15 rows"], ["q / Esc", "Quit"], @@ -402,10 +462,11 @@ export default function Home() {
- {" PROJECT BRANCH LAST USE"} + {" PROJECT BRANCH LAST USE"}
- [✓] + + [✓] {" "} acme-api{" "} @@ -414,19 +475,24 @@ export default function Home() { {" "}25m ago
+ + 2m [✓] quantum-dashboard{" "} feat/cha {" "}1h ago
- [✓] + + [✓] ml-pipeline{" "} exp/bert {" "}just now
-
- [ ] pixel-engine{" "}develop{" "}3h ago +
+ + [ ] + pixel-engine{" "}develop{" "}3h ago
diff --git a/site/app/terminal-cascade.tsx b/site/app/terminal-cascade.tsx index 6ef619e..88263cd 100644 --- a/site/app/terminal-cascade.tsx +++ b/site/app/terminal-cascade.tsx @@ -4,9 +4,9 @@ import { useEffect, useState, useCallback } from "react"; import Image from "next/image"; const projects = [ - { name: "acme-api", branch: "main", time: "25m ago" }, - { name: "quantum-dash", branch: "feat/charts", time: "1h ago" }, - { name: "ml-pipeline", branch: "exp/bert", time: "just now" }, + { name: "acme-api", branch: "main", time: "25m ago", status: "busy" as const }, + { name: "quantum-dash", branch: "feat/charts", time: "1h ago", status: "idle" as const, elapsed: "4m" }, + { name: "ml-pipeline", branch: "exp/bert", time: "just now", status: "busy" as const }, ]; type Phase = @@ -94,6 +94,12 @@ export function TerminalCascade() { {projects.map((proj, i) => { const checked = i < selectedCount; const isActive = i === selectedCount - 1 && phase === "selecting"; + const dot = proj.status === "busy" + ? + : ; + const tag = proj.status === "idle" && proj.elapsed + ? {proj.elapsed.padEnd(2)} + : ; return (
+ {dot}{tag} {checked ? "[✓]" : "[ ]"} - {proj.name.padEnd(20)} + {proj.name.padEnd(18)} {proj.branch.padEnd(13)} {proj.time}
); })}
- [ ] pixel-engine{" "}develop{" "}3h ago + [ ] pixel-engine{" "}develop{" "}3h ago
{/* Enter hint */} diff --git a/src/data/history.ts b/src/data/history.ts index a1425ef..3d9cf64 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -64,6 +64,9 @@ export async function discoverProjects(): Promise { sessionCount, totalMessages: info.msgs, tags: getTags(path), + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, diff --git a/src/data/mock.ts b/src/data/mock.ts index 0bc337e..61dfab3 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -21,6 +21,9 @@ export function generateMockProjects(): Project[] { sessionCount: 14, totalMessages: 342, tags: "ts bun hono", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -40,6 +43,9 @@ export function generateMockProjects(): Project[] { sessionCount: 8, totalMessages: 187, tags: "ts react vite", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -59,6 +65,9 @@ export function generateMockProjects(): Project[] { sessionCount: 22, totalMessages: 891, tags: "rust wgpu", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -78,6 +87,9 @@ export function generateMockProjects(): Project[] { sessionCount: 5, totalMessages: 78, tags: "go docker k8s", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -97,6 +109,9 @@ export function generateMockProjects(): Project[] { sessionCount: 11, totalMessages: 256, tags: "ts rn expo", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -116,6 +131,9 @@ export function generateMockProjects(): Project[] { sessionCount: 3, totalMessages: 45, tags: "ts astro mdx", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -135,6 +153,9 @@ export function generateMockProjects(): Project[] { sessionCount: 19, totalMessages: 523, tags: "py torch", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -154,6 +175,9 @@ export function generateMockProjects(): Project[] { sessionCount: 2, totalMessages: 31, tags: "ts express pg", + activeSessions: 0, + busySessions: 0, + lastActivityMs: 0, expanded: false, sessions: null, branches: null, @@ -399,6 +423,22 @@ const mockBranchData: Record = { ], } +export function generateMockBusySessions(projects: Project[]): void { + const now = Date.now() + for (const p of projects) { + if (p.activeSessions > 0) { + const isBusy = Math.random() > 0.4 + p.busySessions = isBusy ? Math.min(p.activeSessions, 1 + Math.floor(Math.random() * p.activeSessions)) : 0 + p.lastActivityMs = isBusy + ? now - Math.floor(Math.random() * 3000) + : now - (10_000 + Math.floor(Math.random() * 600_000)) + } else { + p.busySessions = 0 + p.lastActivityMs = 0 + } + } +} + export function generateMockSessions(projectPath: string): SessionInfo[] { const name = projectPath.split("/").pop() || "" return mockSessionData[name] || [] diff --git a/src/data/monitor.ts b/src/data/monitor.ts new file mode 100644 index 0000000..b71cd39 --- /dev/null +++ b/src/data/monitor.ts @@ -0,0 +1,226 @@ +import { readdirSync, statSync } from "node:fs" +import { join } from "node:path" +import type { Project } from "../lib/types" + +const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects` +const BUSY_THRESHOLD_MS = 5000 + +export interface ActiveSession { + pid: string + cwd: string + tty: string + sessionFile: string | null + busy: boolean + lastActivityMs: number +} + +// path → list of active sessions with tty info +const sessionsByPath = new Map() + +function cwdToProjectKey(cwd: string): string { + return cwd.replaceAll("/", "-") +} + +function findActiveJsonl(projectKey: string): { path: string; mtime: number } | null { + const projDir = join(PROJECTS_DIR, projectKey) + try { + const files = readdirSync(projDir).filter(f => f.endsWith(".jsonl")) + let best: { path: string; mtime: number } | null = null + for (const f of files) { + const full = join(projDir, f) + try { + const st = statSync(full) + const mt = st.mtimeMs + if (!best || mt > best.mtime) best = { path: full, mtime: mt } + } catch {} + } + return best + } catch { + return null + } +} + +export async function detectActiveSessions(): Promise> { + const result = new Map() + sessionsByPath.clear() + + let pids: string[] + try { + const proc = Bun.spawn(["pgrep", "-f", "^claude"], { + stdout: "pipe", + stderr: "ignore", + }) + const text = await new Response(proc.stdout).text() + await proc.exited + pids = text.trim().split("\n").filter(Boolean) + } catch { + return result + } + + if (pids.length === 0) return result + + const infoPromises = pids.map(async (pid): Promise => { + try { + const proc = Bun.spawn(["lsof", "-p", pid, "-a", "-d", "cwd,0", "-F", "nf"], { + stdout: "pipe", + stderr: "ignore", + }) + const text = await new Response(proc.stdout).text() + await proc.exited + + let cwd = "" + let tty = "" + let currentFd = "" + + for (const line of text.split("\n")) { + if (line.startsWith("f")) { + currentFd = line.slice(1) + } else if (line.startsWith("n") && line.length > 1) { + const val = line.slice(1) + if (currentFd === "cwd") cwd = val + else if (currentFd === "0" && val.startsWith("/dev/")) tty = val + } + } + + if (cwd) { + const key = cwdToProjectKey(cwd) + const jsonl = findActiveJsonl(key) + const now = Date.now() + const busy = jsonl ? (now - jsonl.mtime) < BUSY_THRESHOLD_MS : false + + return { pid, cwd, tty, sessionFile: jsonl?.path ?? null, busy, lastActivityMs: jsonl?.mtime ?? 0 } + } + } catch {} + return null + }) + + const infos = await Promise.all(infoPromises) + for (const info of infos) { + if (!info) continue + result.set(info.cwd, (result.get(info.cwd) || 0) + 1) + if (!sessionsByPath.has(info.cwd)) sessionsByPath.set(info.cwd, []) + sessionsByPath.get(info.cwd)!.push(info) + } + + return result +} + +export function getSessionTtys(projectPath: string): string[] { + const sessions = sessionsByPath.get(projectPath) + if (!sessions) return [] + return sessions.map(s => s.tty).filter(Boolean) +} + +export function getBusyCount(projectPath: string): number { + const sessions = sessionsByPath.get(projectPath) + if (!sessions) return 0 + return sessions.filter(s => s.busy).length +} + +export function getLastActivityMs(projectPath: string): number { + const sessions = sessionsByPath.get(projectPath) + if (!sessions) return 0 + let best = 0 + for (const s of sessions) { + if (s.lastActivityMs > best) best = s.lastActivityMs + } + return best +} + +export async function focusTerminalByPath(projectPath: string): Promise { + const ttys = getSessionTtys(projectPath) + if (ttys.length === 0) return false + + const tty = ttys[0] + const script = ` +tell application "Terminal" + activate + repeat with w in windows + repeat with t in tabs of w + if tty of t is "${tty}" then + set selected of t to true + set index of w to 1 + return true + end if + end repeat + end repeat +end tell +return false` + + try { + const proc = Bun.spawn(["osascript", "-e", script], { + stdout: "pipe", + stderr: "ignore", + }) + const out = await new Response(proc.stdout).text() + await proc.exited + const focused = out.trim() === "true" + + if (focused) { + const ttys = getSessionTtys(projectPath) + for (const tty of ttys) { + try { + await Bun.write(tty, "\x07") + } catch {} + } + } + + return focused + } catch { + return false + } +} + +export function updateProjectSessions(projects: Project[], sessions: Map): boolean { + let changed = false + for (const project of projects) { + const count = sessions.get(project.path) || 0 + const busy = getBusyCount(project.path) + const activity = getLastActivityMs(project.path) + if (project.activeSessions !== count || project.busySessions !== busy || project.lastActivityMs !== activity) { + project.activeSessions = count + project.busySessions = busy + project.lastActivityMs = activity + changed = true + } + } + return changed +} + +export function checkTransitions( + projects: Project[], + prevBusy: Map +): string[] { + const transitioned: string[] = [] + for (const project of projects) { + const prev = prevBusy.get(project.path) || 0 + if (prev > 0 && project.busySessions === 0 && project.activeSessions > 0) { + transitioned.push(project.name) + } + } + return transitioned +} + +export function snapshotBusy(projects: Project[]): Map { + const snap = new Map() + for (const p of projects) { + snap.set(p.path, p.busySessions) + } + return snap +} + +export function playDoneSound(): void { + Bun.spawn(["afplay", "/System/Library/Sounds/Glass.aiff"], { + stdout: "ignore", + stderr: "ignore", + }) +} + +export function generateMockActiveSessions(projects: Project[]): void { + const indices = Array.from(projects.keys()) + const shuffled = indices.sort(() => Math.random() - 0.5) + const activeCount = Math.min(3 + Math.floor(Math.random() * 2), projects.length) + for (let i = 0; i < activeCount; i++) { + projects[shuffled[i]].activeSessions = 1 + Math.floor(Math.random() * 2) + } +} diff --git a/src/index.ts b/src/index.ts index ac9758e..5d8c82a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -20,13 +20,15 @@ import { import { discoverProjects } from "./data/history" import { loadGitMetadata, loadBranches } from "./data/git" import { loadSessions } from "./data/sessions" -import { generateMockProjects, generateMockSessions, generateMockBranches } from "./data/mock" +import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock" +import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound } from "./data/monitor" import { launchSelections } from "./actions/launcher" import type { Project, DisplayRow } from "./lib/types" -import { timeAgo, formatSize } from "./lib/time" +import { timeAgo, formatSize, elapsedCompact } from "./lib/time" // ─── Theme ────────────────────────────────────────────────────────── const CURSOR_BG = "#283457" +const ACTIVE_BG = "#1a2e1a" const ACCENT = "#7aa2f7" const DIM_CLR = "#565f89" @@ -41,6 +43,8 @@ let sortMode = 0 const sortLabels = ["recent", "name", "commit", "sessions"] let sortedIndices: number[] = [] let displayRows: DisplayRow[] = [] +let monitorInterval: ReturnType | null = null +let prevBusySnapshot: Map = new Map() // ─── UI Refs ──────────────────────────────────────────────────────── let renderer: CliRenderer @@ -111,6 +115,21 @@ function fmtSyncIndicator(ahead: number, behind: number): string { } function fmtProjectRow(project: Project, isSelected: boolean) { + let activeDot: string + let activeTag: string + if (project.activeSessions > 0) { + if (project.busySessions > 0) { + activeDot = green("●") + activeTag = project.activeSessions > 1 ? yellow(String(project.activeSessions)) : " " + } else { + activeDot = yellow("◉") + const elapsed = elapsedCompact(project.lastActivityMs) + activeTag = elapsed ? dim(elapsed.padEnd(2).slice(0, 2)) : " " + } + } else { + activeDot = dim("○") + activeTag = " " + } const check = isSelected ? green("✓") : " " const arrow = project.expanded ? "▼" : "▶" const name = @@ -137,7 +156,7 @@ function fmtProjectRow(project: Project, isSelected: boolean) { else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9)) else claudeCol = dim(ca.padEnd(9)) - return t` [${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( + return t` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( (project.commitAge || "-").padEnd(10) )}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim( String(project.sessionCount).padStart(3) @@ -204,9 +223,14 @@ function updateHeader() { const total = selectedProjects.size + selectedSessions.size const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : "" 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}` } function updateColumnHeaders() { @@ -216,7 +240,7 @@ function updateColumnHeaders() { function updateFooter() { footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ a all │ n none │ s sort │ enter launch │ q quit" + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ a all │ n none │ s sort │ enter launch │ q quit" )}` } @@ -291,11 +315,14 @@ function rebuildList() { content = fmtNewSessionRow(row.projectIndex, isSel) } - if (isCursor) { + const isActive = row.type === "project" && project.activeSessions > 0 + const bgColor = isCursor ? CURSOR_BG : isActive ? ACTIVE_BG : undefined + + if (bgColor) { listBox.add( Box( { - backgroundColor: CURSOR_BG, + backgroundColor: bgColor, shouldFill: true, width: "100%", height: rowHeight, @@ -423,6 +450,22 @@ function handleKeypress(key: KeyEvent) { break } + case "f": { + const row = displayRows[cursor] + const project = projects[row.projectIndex] + Bun.spawn(["open", project.path]) + break + } + + case "g": { + const row = displayRows[cursor] + const project = projects[row.projectIndex] + if (project.activeSessions > 0) { + focusTerminalByPath(project.path) + } + return + } + case "a": for (const p of projects) selectedProjects.add(p.path) break @@ -439,12 +482,25 @@ function handleKeypress(key: KeyEvent) { cursor = 0 break - case "return": + case "return": { + // If cursor is on a project row with active session and nothing selected, focus it + const returnRow = displayRows[cursor] + if ( + returnRow.type === "project" && + projects[returnRow.projectIndex].activeSessions > 0 && + selectedProjects.size === 0 && + selectedSessions.size === 0 + ) { + focusTerminalByPath(projects[returnRow.projectIndex].path) + return + } doLaunch() return + } case "q": case "escape": + if (monitorInterval) clearInterval(monitorInterval) renderer.destroy() return @@ -489,6 +545,7 @@ async function expandProject(projectIndex: number) { async function doLaunch() { if (selectedProjects.size === 0 && selectedSessions.size === 0) return + if (monitorInterval) clearInterval(monitorInterval) if (demoMode) { const total = selectedProjects.size + selectedSessions.size renderer.destroy() @@ -598,6 +655,38 @@ async function main() { updateFooter() renderer.keyInput.on("keypress", handleKeypress) + + // Live session monitoring + if (demoMode) { + generateMockActiveSessions(projects) + generateMockBusySessions(projects) + prevBusySnapshot = snapshotBusy(projects) + updateAll() + } else { + detectActiveSessions().then((sessions) => { + if (updateProjectSessions(projects, sessions)) updateAll() + prevBusySnapshot = snapshotBusy(projects) + }) + } + + monitorInterval = setInterval(async () => { + if (demoMode) { + for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 } + generateMockActiveSessions(projects) + generateMockBusySessions(projects) + const transitioned = checkTransitions(projects, prevBusySnapshot) + prevBusySnapshot = snapshotBusy(projects) + if (transitioned.length > 0) playDoneSound() + 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 (changed) updateAll() + } + }, 5000) } main().catch((err) => { diff --git a/src/lib/time.ts b/src/lib/time.ts index 977e62a..18078b6 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -10,6 +10,16 @@ export function timeAgo(ms: number): string { return `${Math.floor(diff / 2592000)}mo ago` } +export function elapsedCompact(ms: number): string { + if (!ms) return "" + const sec = Math.floor((Date.now() - ms) / 1000) + if (sec < 5) return "" + if (sec < 60) return `${sec}s` + if (sec < 3600) return `${Math.floor(sec / 60)}m` + if (sec < 86400) return `${Math.floor(sec / 3600)}h` + return `${Math.floor(sec / 86400)}d` +} + export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` diff --git a/src/lib/types.ts b/src/lib/types.ts index 46bac13..75110f5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -22,6 +22,9 @@ export interface Project { sessionCount: number totalMessages: number tags: string + activeSessions: number + busySessions: number + lastActivityMs: number expanded: boolean sessions: SessionInfo[] | null branches: BranchInfo[] | null