feat: live session monitoring with busy/idle indicators and sound notification
Detect active Claude sessions' real-time status by monitoring JSONL file modification times. Shows green dot when Claude is processing, yellow dot with elapsed time when idle. Plays Glass.aiff when sessions transition from busy to idle. Updates website and README with new features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
README.md
18
README.md
@@ -5,7 +5,7 @@
|
|||||||
<h3 align="center">TUI launcher for Claude Code sessions</h3>
|
<h3 align="center">TUI launcher for Claude Code sessions</h3>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -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.
|
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
|
## Screenshots
|
||||||
|
|
||||||
### Project list
|
### Project list
|
||||||
@@ -96,6 +110,8 @@ Select a branch to launch Claude with a prompt to switch to that branch. Select
|
|||||||
| `a` | Select all |
|
| `a` | Select all |
|
||||||
| `n` | Deselect all |
|
| `n` | Deselect all |
|
||||||
| `s` | Cycle sort mode (recent → name → commit → sessions) |
|
| `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 |
|
| `Enter` | Launch selected in Terminal.app |
|
||||||
| `PageUp` `PageDown` | Jump 15 rows |
|
| `PageUp` `PageDown` | Jump 15 rows |
|
||||||
| `Home` `End` | Jump to top/bottom |
|
| `Home` `End` | Jump to top/bottom |
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
LinkedinIcon,
|
LinkedinIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
SpaceInvadersIcon,
|
SpaceInvadersIcon,
|
||||||
|
EyeIcon,
|
||||||
|
BellIcon,
|
||||||
} from "raster-react";
|
} from "raster-react";
|
||||||
|
|
||||||
function PixelDivider() {
|
function PixelDivider() {
|
||||||
@@ -139,9 +141,9 @@ export default function Home() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="font-[family-name:var(--font-mono)] text-dim text-sm max-w-md leading-relaxed mb-8">
|
<p className="font-[family-name:var(--font-mono)] text-dim text-sm max-w-md leading-relaxed mb-8">
|
||||||
Browse all your projects. See git status at a glance. Expand
|
Browse all your projects. See git status at a glance. Monitor
|
||||||
into sessions and branches. Launch everything in parallel
|
which sessions are busy or idle in real time. Get notified when
|
||||||
Terminal windows.
|
Claude finishes. Launch everything in parallel Terminal windows.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Install command */}
|
{/* Install command */}
|
||||||
@@ -261,6 +263,52 @@ export default function Home() {
|
|||||||
|
|
||||||
<PixelDivider />
|
<PixelDivider />
|
||||||
|
|
||||||
|
{/* ══════ LIVE MONITORING ══════ */}
|
||||||
|
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||||
|
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||||
|
// LIVE SESSION MONITORING
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="pixel-border bg-surface p-6">
|
||||||
|
<p className="font-[family-name:var(--font-mono)] text-dim text-xs leading-relaxed mb-5">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3 font-[family-name:var(--font-mono)] text-xs">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-green text-base">●</span>
|
||||||
|
<span className="text-text">Busy</span>
|
||||||
|
<span className="text-dim">— Claude is actively processing</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-yellow text-base">◉</span>
|
||||||
|
<span className="text-dim">3m</span>
|
||||||
|
<span className="text-text">Idle</span>
|
||||||
|
<span className="text-dim">— Claude finished 3 min ago, waiting for input</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-dim text-base">○</span>
|
||||||
|
<span className="text-text ml-[22px]">No session</span>
|
||||||
|
<span className="text-dim">— No active Claude process</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 pt-4 border-t border-border">
|
||||||
|
<p className="font-[family-name:var(--font-mono)] text-dim text-[10px] leading-relaxed">
|
||||||
|
Detection works by monitoring JSONL file modification times in{" "}
|
||||||
|
<code className="text-accent">~/.claude/projects/</code>. Sessions
|
||||||
|
writing within 5 seconds are busy; otherwise idle. Header shows
|
||||||
|
total busy/idle count across all projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PixelDivider />
|
||||||
|
|
||||||
{/* ══════ FEATURES ══════ */}
|
{/* ══════ FEATURES ══════ */}
|
||||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||||
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
<h2 className="font-[family-name:var(--font-pixel)] text-accent text-sm uppercase tracking-[0.3em] mb-12 text-center">
|
||||||
@@ -288,6 +336,16 @@ export default function Home() {
|
|||||||
title="BRANCH SWITCHING"
|
title="BRANCH SWITCHING"
|
||||||
desc="Select a non-current branch to launch Claude with an auto-prompt to switch and stash."
|
desc="Select a non-current branch to launch Claude with an auto-prompt to switch and stash."
|
||||||
/>
|
/>
|
||||||
|
<FeatureBlock
|
||||||
|
icon={<EyeIcon size={28} />}
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
<FeatureBlock
|
||||||
|
icon={<BellIcon size={28} />}
|
||||||
|
title="DONE SOUND"
|
||||||
|
desc="Plays a sound when Claude finishes responding. Never miss a completed task — even across multiple sessions."
|
||||||
|
/>
|
||||||
<FeatureBlock
|
<FeatureBlock
|
||||||
icon={<TerminalIcon size={28} />}
|
icon={<TerminalIcon size={28} />}
|
||||||
title="PARALLEL LAUNCH"
|
title="PARALLEL LAUNCH"
|
||||||
@@ -320,6 +378,8 @@ export default function Home() {
|
|||||||
["a", "Select all"],
|
["a", "Select all"],
|
||||||
["n", "Deselect all"],
|
["n", "Deselect all"],
|
||||||
["s", "Cycle sort mode"],
|
["s", "Cycle sort mode"],
|
||||||
|
["f", "Open folder in Finder"],
|
||||||
|
["g", "Go to active session"],
|
||||||
["Enter", "Launch selected"],
|
["Enter", "Launch selected"],
|
||||||
["PgUp PgDn", "Jump 15 rows"],
|
["PgUp PgDn", "Jump 15 rows"],
|
||||||
["q / Esc", "Quit"],
|
["q / Esc", "Quit"],
|
||||||
@@ -402,10 +462,11 @@ export default function Home() {
|
|||||||
<TerminalWindow title="cladm — 3 selected">
|
<TerminalWindow title="cladm — 3 selected">
|
||||||
<div className="p-3 font-[family-name:var(--font-mono)] text-[10px] leading-relaxed">
|
<div className="p-3 font-[family-name:var(--font-mono)] text-[10px] leading-relaxed">
|
||||||
<div className="text-dim mb-1">
|
<div className="text-dim mb-1">
|
||||||
{" PROJECT BRANCH LAST USE"}
|
{" PROJECT BRANCH LAST USE"}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#283457] px-1">
|
<div className="bg-[#283457] px-1">
|
||||||
<span className="text-green">[✓]</span>
|
<span className="text-green">●</span>
|
||||||
|
<span className="text-green"> [✓]</span>
|
||||||
<span className="text-text">
|
<span className="text-text">
|
||||||
{" "}
|
{" "}
|
||||||
acme-api{" "}
|
acme-api{" "}
|
||||||
@@ -414,19 +475,24 @@ export default function Home() {
|
|||||||
<span className="text-cyan">{" "}25m ago</span>
|
<span className="text-cyan">{" "}25m ago</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
|
<span className="text-yellow">◉</span>
|
||||||
|
<span className="text-dim">2m</span>
|
||||||
<span className="text-green">[✓]</span>
|
<span className="text-green">[✓]</span>
|
||||||
<span className="text-text"> quantum-dashboard{" "}</span>
|
<span className="text-text"> quantum-dashboard{" "}</span>
|
||||||
<span className="text-magenta">feat/cha</span>
|
<span className="text-magenta">feat/cha</span>
|
||||||
<span className="text-cyan">{" "}1h ago</span>
|
<span className="text-cyan">{" "}1h ago</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<span className="text-green">[✓]</span>
|
<span className="text-green">●</span>
|
||||||
|
<span className="text-green"> [✓]</span>
|
||||||
<span className="text-text"> ml-pipeline{" "}</span>
|
<span className="text-text"> ml-pipeline{" "}</span>
|
||||||
<span className="text-magenta">exp/bert</span>
|
<span className="text-magenta">exp/bert</span>
|
||||||
<span className="text-cyan">{" "}just now</span>
|
<span className="text-cyan">{" "}just now</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1 text-dim">
|
<div className="px-1">
|
||||||
[ ] pixel-engine{" "}develop{" "}3h ago
|
<span className="text-dim">○</span>
|
||||||
|
<span className="text-dim"> [ ]</span>
|
||||||
|
<span className="text-dim"> pixel-engine{" "}develop{" "}3h ago</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useEffect, useState, useCallback } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
const projects = [
|
const projects = [
|
||||||
{ name: "acme-api", branch: "main", time: "25m ago" },
|
{ name: "acme-api", branch: "main", time: "25m ago", status: "busy" as const },
|
||||||
{ name: "quantum-dash", branch: "feat/charts", time: "1h ago" },
|
{ name: "quantum-dash", branch: "feat/charts", time: "1h ago", status: "idle" as const, elapsed: "4m" },
|
||||||
{ name: "ml-pipeline", branch: "exp/bert", time: "just now" },
|
{ name: "ml-pipeline", branch: "exp/bert", time: "just now", status: "busy" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
@@ -94,6 +94,12 @@ export function TerminalCascade() {
|
|||||||
{projects.map((proj, i) => {
|
{projects.map((proj, i) => {
|
||||||
const checked = i < selectedCount;
|
const checked = i < selectedCount;
|
||||||
const isActive = i === selectedCount - 1 && phase === "selecting";
|
const isActive = i === selectedCount - 1 && phase === "selecting";
|
||||||
|
const dot = proj.status === "busy"
|
||||||
|
? <span className="text-green">●</span>
|
||||||
|
: <span className="text-yellow">◉</span>;
|
||||||
|
const tag = proj.status === "idle" && proj.elapsed
|
||||||
|
? <span className="text-dim">{proj.elapsed.padEnd(2)}</span>
|
||||||
|
: <span> </span>;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${proj.name}-${cycle}`}
|
key={`${proj.name}-${cycle}`}
|
||||||
@@ -101,17 +107,18 @@ export function TerminalCascade() {
|
|||||||
isActive ? "bg-[#283457]" : ""
|
isActive ? "bg-[#283457]" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{dot}{tag}
|
||||||
<span className={checked ? "text-green" : "text-dim"}>
|
<span className={checked ? "text-green" : "text-dim"}>
|
||||||
{checked ? "[✓]" : "[ ]"}
|
{checked ? "[✓]" : "[ ]"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-text"> {proj.name.padEnd(20)}</span>
|
<span className="text-text"> {proj.name.padEnd(18)}</span>
|
||||||
<span className="text-magenta">{proj.branch.padEnd(13)}</span>
|
<span className="text-magenta">{proj.branch.padEnd(13)}</span>
|
||||||
<span className="text-cyan">{proj.time}</span>
|
<span className="text-cyan">{proj.time}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="px-1 text-dim">
|
<div className="px-1 text-dim">
|
||||||
[ ] pixel-engine{" "}develop{" "}3h ago
|
<span>○</span><span> </span>[ ] pixel-engine{" "}develop{" "}3h ago
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enter hint */}
|
{/* Enter hint */}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export async function discoverProjects(): Promise<Project[]> {
|
|||||||
sessionCount,
|
sessionCount,
|
||||||
totalMessages: info.msgs,
|
totalMessages: info.msgs,
|
||||||
tags: getTags(path),
|
tags: getTags(path),
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 14,
|
sessionCount: 14,
|
||||||
totalMessages: 342,
|
totalMessages: 342,
|
||||||
tags: "ts bun hono",
|
tags: "ts bun hono",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -40,6 +43,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 8,
|
sessionCount: 8,
|
||||||
totalMessages: 187,
|
totalMessages: 187,
|
||||||
tags: "ts react vite",
|
tags: "ts react vite",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -59,6 +65,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 22,
|
sessionCount: 22,
|
||||||
totalMessages: 891,
|
totalMessages: 891,
|
||||||
tags: "rust wgpu",
|
tags: "rust wgpu",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -78,6 +87,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 5,
|
sessionCount: 5,
|
||||||
totalMessages: 78,
|
totalMessages: 78,
|
||||||
tags: "go docker k8s",
|
tags: "go docker k8s",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -97,6 +109,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 11,
|
sessionCount: 11,
|
||||||
totalMessages: 256,
|
totalMessages: 256,
|
||||||
tags: "ts rn expo",
|
tags: "ts rn expo",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -116,6 +131,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 3,
|
sessionCount: 3,
|
||||||
totalMessages: 45,
|
totalMessages: 45,
|
||||||
tags: "ts astro mdx",
|
tags: "ts astro mdx",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -135,6 +153,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 19,
|
sessionCount: 19,
|
||||||
totalMessages: 523,
|
totalMessages: 523,
|
||||||
tags: "py torch",
|
tags: "py torch",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -154,6 +175,9 @@ export function generateMockProjects(): Project[] {
|
|||||||
sessionCount: 2,
|
sessionCount: 2,
|
||||||
totalMessages: 31,
|
totalMessages: 31,
|
||||||
tags: "ts express pg",
|
tags: "ts express pg",
|
||||||
|
activeSessions: 0,
|
||||||
|
busySessions: 0,
|
||||||
|
lastActivityMs: 0,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
sessions: null,
|
sessions: null,
|
||||||
branches: null,
|
branches: null,
|
||||||
@@ -399,6 +423,22 @@ const mockBranchData: Record<string, BranchInfo[]> = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[] {
|
export function generateMockSessions(projectPath: string): SessionInfo[] {
|
||||||
const name = projectPath.split("/").pop() || ""
|
const name = projectPath.split("/").pop() || ""
|
||||||
return mockSessionData[name] || []
|
return mockSessionData[name] || []
|
||||||
|
|||||||
226
src/data/monitor.ts
Normal file
226
src/data/monitor.ts
Normal file
@@ -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<string, ActiveSession[]>()
|
||||||
|
|
||||||
|
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<Map<string, number>> {
|
||||||
|
const result = new Map<string, number>()
|
||||||
|
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<ActiveSession | null> => {
|
||||||
|
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<boolean> {
|
||||||
|
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<string, number>): 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, number>
|
||||||
|
): 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<string, number> {
|
||||||
|
const snap = new Map<string, number>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/index.ts
105
src/index.ts
@@ -20,13 +20,15 @@ import {
|
|||||||
import { discoverProjects } from "./data/history"
|
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 } 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 { launchSelections } from "./actions/launcher"
|
||||||
import type { Project, DisplayRow } from "./lib/types"
|
import type { Project, DisplayRow } from "./lib/types"
|
||||||
import { timeAgo, formatSize } from "./lib/time"
|
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
||||||
|
|
||||||
// ─── Theme ──────────────────────────────────────────────────────────
|
// ─── Theme ──────────────────────────────────────────────────────────
|
||||||
const CURSOR_BG = "#283457"
|
const CURSOR_BG = "#283457"
|
||||||
|
const ACTIVE_BG = "#1a2e1a"
|
||||||
const ACCENT = "#7aa2f7"
|
const ACCENT = "#7aa2f7"
|
||||||
const DIM_CLR = "#565f89"
|
const DIM_CLR = "#565f89"
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ let sortMode = 0
|
|||||||
const sortLabels = ["recent", "name", "commit", "sessions"]
|
const sortLabels = ["recent", "name", "commit", "sessions"]
|
||||||
let sortedIndices: number[] = []
|
let sortedIndices: number[] = []
|
||||||
let displayRows: DisplayRow[] = []
|
let displayRows: DisplayRow[] = []
|
||||||
|
let monitorInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let prevBusySnapshot: Map<string, number> = new Map()
|
||||||
|
|
||||||
// ─── UI Refs ────────────────────────────────────────────────────────
|
// ─── UI Refs ────────────────────────────────────────────────────────
|
||||||
let renderer: CliRenderer
|
let renderer: CliRenderer
|
||||||
@@ -111,6 +115,21 @@ function fmtSyncIndicator(ahead: number, behind: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtProjectRow(project: Project, isSelected: boolean) {
|
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 check = isSelected ? green("✓") : " "
|
||||||
const arrow = project.expanded ? "▼" : "▶"
|
const arrow = project.expanded ? "▼" : "▶"
|
||||||
const name =
|
const name =
|
||||||
@@ -137,7 +156,7 @@ function fmtProjectRow(project: Project, isSelected: boolean) {
|
|||||||
else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9))
|
else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9))
|
||||||
else claudeCol = dim(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.commitAge || "-").padEnd(10)
|
||||||
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
|
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
|
||||||
String(project.sessionCount).padStart(3)
|
String(project.sessionCount).padStart(3)
|
||||||
@@ -204,9 +223,14 @@ function updateHeader() {
|
|||||||
const total = selectedProjects.size + selectedSessions.size
|
const total = selectedProjects.size + selectedSessions.size
|
||||||
const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : ""
|
const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : ""
|
||||||
const modeLabel = demoMode ? " [DEMO]" : ""
|
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(
|
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}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateColumnHeaders() {
|
function updateColumnHeaders() {
|
||||||
@@ -216,7 +240,7 @@ function updateColumnHeaders() {
|
|||||||
|
|
||||||
function updateFooter() {
|
function updateFooter() {
|
||||||
footerText.content = t` ${dim(
|
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)
|
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(
|
listBox.add(
|
||||||
Box(
|
Box(
|
||||||
{
|
{
|
||||||
backgroundColor: CURSOR_BG,
|
backgroundColor: bgColor,
|
||||||
shouldFill: true,
|
shouldFill: true,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: rowHeight,
|
height: rowHeight,
|
||||||
@@ -423,6 +450,22 @@ function handleKeypress(key: KeyEvent) {
|
|||||||
break
|
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":
|
case "a":
|
||||||
for (const p of projects) selectedProjects.add(p.path)
|
for (const p of projects) selectedProjects.add(p.path)
|
||||||
break
|
break
|
||||||
@@ -439,12 +482,25 @@ function handleKeypress(key: KeyEvent) {
|
|||||||
cursor = 0
|
cursor = 0
|
||||||
break
|
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()
|
doLaunch()
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case "q":
|
case "q":
|
||||||
case "escape":
|
case "escape":
|
||||||
|
if (monitorInterval) clearInterval(monitorInterval)
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -489,6 +545,7 @@ async function expandProject(projectIndex: number) {
|
|||||||
|
|
||||||
async function doLaunch() {
|
async function doLaunch() {
|
||||||
if (selectedProjects.size === 0 && selectedSessions.size === 0) return
|
if (selectedProjects.size === 0 && selectedSessions.size === 0) return
|
||||||
|
if (monitorInterval) clearInterval(monitorInterval)
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
const total = selectedProjects.size + selectedSessions.size
|
const total = selectedProjects.size + selectedSessions.size
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
@@ -598,6 +655,38 @@ async function main() {
|
|||||||
updateFooter()
|
updateFooter()
|
||||||
|
|
||||||
renderer.keyInput.on("keypress", handleKeypress)
|
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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export function timeAgo(ms: number): string {
|
|||||||
return `${Math.floor(diff / 2592000)}mo ago`
|
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 {
|
export function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes}B`
|
if (bytes < 1024) return `${bytes}B`
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface Project {
|
|||||||
sessionCount: number
|
sessionCount: number
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
tags: string
|
tags: string
|
||||||
|
activeSessions: number
|
||||||
|
busySessions: number
|
||||||
|
lastActivityMs: number
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
sessions: SessionInfo[] | null
|
sessions: SessionInfo[] | null
|
||||||
branches: BranchInfo[] | null
|
branches: BranchInfo[] | null
|
||||||
|
|||||||
Reference in New Issue
Block a user