Add TUI launcher implementation and project docs

Source modules for history parsing, git metadata, project scanning,
terminal launching, and OpenTUI component layout. Remove private flag
for publishing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 22:56:54 +00:00
parent 165ad7d352
commit fcafe652cf
14 changed files with 2757 additions and 91 deletions

97
src/actions/launcher.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { Project } from "../lib/types"
interface LaunchItem {
path: string
sessionId?: string
targetBranch?: string
}
export async function launchSelections(
projects: Project[],
selectedProjects: Set<string>,
selectedSessions: Set<string>,
selectedBranches: Map<string, string> = new Map()
): Promise<number> {
const byProject = new Map<string, LaunchItem[]>()
for (const path of selectedProjects) {
if (!byProject.has(path)) byProject.set(path, [])
const targetBranch = selectedBranches.get(path)
const project = projects.find(p => p.path === path)
const needsBranch = targetBranch && project && targetBranch !== project.branch
byProject.get(path)!.push({ path, targetBranch: needsBranch ? targetBranch : undefined })
}
for (const project of projects) {
if (!project.sessions) continue
for (const session of project.sessions) {
if (selectedSessions.has(session.id)) {
if (!byProject.has(project.path)) byProject.set(project.path, [])
const targetBranch = selectedBranches.get(project.path)
const needsBranch = targetBranch && targetBranch !== project.branch
byProject.get(project.path)!.push({
path: project.path,
sessionId: session.id,
targetBranch: needsBranch ? targetBranch : undefined,
})
}
}
}
let count = 0
for (const [, items] of byProject) {
const first = items[0]
const firstCmd = buildCmd(first)
const newWindowScript = [
'tell application "Terminal"',
" activate",
` do script "${escapeAS(firstCmd)}"`,
"end tell",
].join("\n")
await runOsascript(newWindowScript)
count++
for (let i = 1; i < items.length; i++) {
await Bun.sleep(400)
const cmd = buildCmd(items[i])
await runOsascript(
'tell application "System Events" to keystroke "t" using command down'
)
await Bun.sleep(300)
await runOsascript(
`tell application "Terminal" to do script "${escapeAS(cmd)}" in front window`
)
count++
}
await Bun.sleep(300)
}
return count
}
function buildCmd(item: LaunchItem): string {
const base = `cd '${item.path}' && claude --dangerously-skip-permissions`
const branchFlag = item.targetBranch
? ` -p "switch to branch ${item.targetBranch}, stash if needed"`
: ""
if (item.sessionId) {
return `${base} --resume '${item.sessionId}'${branchFlag}`
}
return `${base}${branchFlag}`
}
function escapeAS(str: string): string {
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
}
async function runOsascript(script: string): Promise<void> {
const proc = Bun.spawn(["osascript", "-e", script], {
stdout: "ignore",
stderr: "ignore",
})
await proc.exited
}

127
src/data/git.ts Normal file
View File

@@ -0,0 +1,127 @@
import type { BranchInfo, Project } from "../lib/types"
import { timeAgo } from "../lib/time"
async function gitCmd(path: string, ...args: string[]): Promise<string> {
const proc = Bun.spawn(["git", "-C", path, ...args], {
stdout: "pipe",
stderr: "ignore",
})
const text = await new Response(proc.stdout).text()
const code = await proc.exited
if (code !== 0) throw new Error(`git failed: ${code}`)
return text.trim()
}
export async function loadGitMetadata(project: Project): Promise<void> {
const path = project.path
const [branchResult, logResult, statusResult, syncResult] = await Promise.allSettled([
gitCmd(path, "rev-parse", "--abbrev-ref", "HEAD"),
gitCmd(path, "log", "-1", "--format=%ct|%s"),
gitCmd(path, "status", "--porcelain"),
gitCmd(path, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"),
])
if (branchResult.status === "fulfilled") {
project.branch = branchResult.value || "-"
} else {
project.branch = "-"
}
if (logResult.status === "fulfilled") {
const raw = logResult.value
const pipeIdx = raw.indexOf("|")
if (pipeIdx > -1) {
project.commitEpoch = parseInt(raw.slice(0, pipeIdx)) || 0
project.commitAge = timeAgo(project.commitEpoch * 1000)
const msg = raw.slice(pipeIdx + 1)
project.commitMsg = msg.length > 22 ? msg.slice(0, 19) + "..." : msg
}
}
if (statusResult.status === "fulfilled") {
const lines = statusResult.value.split("\n").filter(Boolean)
let staged = 0,
unstaged = 0,
untracked = 0
for (const line of lines) {
if (line.length < 2) continue
const x = line[0],
y = line[1]
if (x === "?" && y === "?") {
untracked++
} else {
if (x !== " " && x !== "?") staged++
if (y !== " " && y !== "?") unstaged++
}
}
const parts: string[] = []
if (staged > 0) parts.push(`+${staged}`)
if (unstaged > 0) parts.push(`~${unstaged}`)
if (untracked > 0) parts.push(`?${untracked}`)
project.dirty = parts.join(" ")
}
if (syncResult.status === "fulfilled") {
const parts = syncResult.value.split("\t")
project.ahead = parseInt(parts[0]) || 0
project.behind = parseInt(parts[1]) || 0
} else {
project.ahead = -1
project.behind = -1
}
}
export async function loadBranches(projectPath: string): Promise<BranchInfo[]> {
let raw: string
try {
raw = await gitCmd(
projectPath,
"branch",
"--sort=-committerdate",
"--format=%(refname:short)|%(HEAD)|%(committerdate:unix)|%(subject)",
)
} catch {
return []
}
if (!raw) return []
const lines = raw.split("\n").filter(Boolean)
const top = lines.slice(0, 5)
const branches: BranchInfo[] = []
for (const line of top) {
const parts = line.split("|")
if (parts.length < 4) continue
const name = parts[0]
const isCurrent = parts[1] === "*"
const epoch = parseInt(parts[2]) || 0
const subject = parts.slice(3).join("|")
const lastCommitAge = timeAgo(epoch * 1000)
const lastCommitMsg = subject.length > 40 ? subject.slice(0, 37) + "..." : subject
let ahead = -1
let behind = -1
try {
const syncOut = await gitCmd(
projectPath,
"rev-list",
"--left-right",
"--count",
`${name}...${name}@{upstream}`,
)
const syncParts = syncOut.split("\t")
ahead = parseInt(syncParts[0]) || 0
behind = parseInt(syncParts[1]) || 0
} catch {
ahead = -1
behind = -1
}
branches.push({ name, isCurrent, lastCommitAge, lastCommitMsg, ahead, behind })
}
return branches
}

75
src/data/history.ts Normal file
View File

@@ -0,0 +1,75 @@
import { existsSync, readdirSync } from "node:fs"
import { join, relative } from "node:path"
import { getTags } from "../lib/tags"
import { timeAgo } from "../lib/time"
import type { Project } from "../lib/types"
const SCAN_ROOT = `${Bun.env.HOME}/Desktop`
const HISTORY_PATH = `${Bun.env.HOME}/.claude/history.jsonl`
const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects`
interface HistoryAgg {
msgs: number
last: number
}
export async function discoverProjects(): Promise<Project[]> {
const file = Bun.file(HISTORY_PATH)
if (!(await file.exists())) return []
const text = await file.text()
const agg = new Map<string, HistoryAgg>()
for (const line of text.split("\n")) {
if (!line.trim()) continue
try {
const d = JSON.parse(line)
const p = d.project as string
const ts = (d.timestamp as number) || 0
if (p && p.startsWith(SCAN_ROOT + "/") && p !== SCAN_ROOT) {
let info = agg.get(p)
if (!info) {
info = { msgs: 0, last: 0 }
agg.set(p, info)
}
info.msgs++
info.last = Math.max(info.last, ts)
}
} catch {}
}
const projects: Project[] = []
for (const [path, info] of agg) {
if (!existsSync(path)) continue
const dirName = path.replaceAll("/", "-")
const projDir = join(PROJECTS_DIR, dirName)
let sessionCount = 0
try {
sessionCount = readdirSync(projDir).filter((f) => f.endsWith(".jsonl")).length
} catch {}
projects.push({
path,
name: relative(SCAN_ROOT, path),
branch: "",
commitAge: "",
commitMsg: "",
commitEpoch: 0,
dirty: "",
ahead: 0,
behind: 0,
claudeAgo: timeAgo(info.last),
claudeLastMs: info.last,
sessionCount,
totalMessages: info.msgs,
tags: getTags(path),
expanded: false,
sessions: null,
branches: null,
})
}
projects.sort((a, b) => b.claudeLastMs - a.claudeLastMs)
return projects
}

410
src/data/mock.ts Normal file
View File

@@ -0,0 +1,410 @@
import type { Project, SessionInfo, BranchInfo } from "../lib/types"
const now = Date.now()
const hour = 3_600_000
const day = 86_400_000
export function generateMockProjects(): Project[] {
return [
{
path: "/Users/demo/projects/acme-api",
name: "acme-api",
branch: "main",
commitAge: "2h ago",
commitMsg: "fix: rate limiter edge case",
commitEpoch: (now - 2 * hour) / 1000,
dirty: "~2 ?1",
ahead: 1,
behind: 0,
claudeAgo: "25m ago",
claudeLastMs: now - 25 * 60_000,
sessionCount: 14,
totalMessages: 342,
tags: "ts bun hono",
expanded: false,
sessions: null,
branches: null,
},
{
path: "/Users/demo/projects/quantum-dashboard",
name: "quantum-dashboard",
branch: "feat/cha",
commitAge: "5h ago",
commitMsg: "add chart tooltip comp",
commitEpoch: (now - 5 * hour) / 1000,
dirty: "+3 ~1",
ahead: 3,
behind: 0,
claudeAgo: "1h ago",
claudeLastMs: now - 1 * hour,
sessionCount: 8,
totalMessages: 187,
tags: "ts react vite",
expanded: false,
sessions: null,
branches: null,
},
{
path: "/Users/demo/projects/pixel-engine",
name: "pixel-engine",
branch: "develop",
commitAge: "1d ago",
commitMsg: "perf: batch draw calls",
commitEpoch: (now - 1 * day) / 1000,
dirty: "",
ahead: 0,
behind: 0,
claudeAgo: "3h ago",
claudeLastMs: now - 3 * hour,
sessionCount: 22,
totalMessages: 891,
tags: "rust wgpu",
expanded: false,
sessions: null,
branches: null,
},
{
path: "/Users/demo/projects/infractl",
name: "infractl",
branch: "main",
commitAge: "3d ago",
commitMsg: "docs: update deploy guide",
commitEpoch: (now - 3 * day) / 1000,
dirty: "?2",
ahead: 0,
behind: 2,
claudeAgo: "2d ago",
claudeLastMs: now - 2 * day,
sessionCount: 5,
totalMessages: 78,
tags: "go docker k8s",
expanded: false,
sessions: null,
branches: null,
},
{
path: "/Users/demo/projects/notely-mobile",
name: "notely-mobile",
branch: "release",
commitAge: "6h ago",
commitMsg: "bump version to 2.4.1",
commitEpoch: (now - 6 * hour) / 1000,
dirty: "",
ahead: 0,
behind: 0,
claudeAgo: "5h ago",
claudeLastMs: now - 5 * hour,
sessionCount: 11,
totalMessages: 256,
tags: "ts rn expo",
expanded: false,
sessions: null,
branches: null,
},
{
path: "/Users/demo/projects/blog-engine",
name: "blog-engine",
branch: "main",
commitAge: "7d ago",
commitMsg: "feat: RSS feed generation",
commitEpoch: (now - 7 * day) / 1000,
dirty: "~1",
ahead: 0,
behind: 0,
claudeAgo: "5d ago",
claudeLastMs: now - 5 * day,
sessionCount: 3,
totalMessages: 45,
tags: "ts astro mdx",
expanded: false,
sessions: null,
branches: null,
},
{
path: "/Users/demo/projects/ml-pipeline",
name: "ml-pipeline",
branch: "exp/bert",
commitAge: "12h ago",
commitMsg: "tune hyperparams batch 3",
commitEpoch: (now - 12 * hour) / 1000,
dirty: "+1 ~4",
ahead: 7,
behind: 0,
claudeAgo: "just now",
claudeLastMs: now - 60_000,
sessionCount: 19,
totalMessages: 523,
tags: "py torch",
expanded: false,
sessions: null,
branches: null,
},
{
path: "/Users/demo/projects/auth-service",
name: "auth-service",
branch: "main",
commitAge: "14d ago",
commitMsg: "chore: dep updates",
commitEpoch: (now - 14 * day) / 1000,
dirty: "",
ahead: -1,
behind: -1,
claudeAgo: "12d ago",
claudeLastMs: now - 12 * day,
sessionCount: 2,
totalMessages: 31,
tags: "ts express pg",
expanded: false,
sessions: null,
branches: null,
},
]
}
const mockSessionData: Record<string, SessionInfo[]> = {
"acme-api": [
{
id: "demo-acme-1",
timestamp: now - 25 * 60_000,
title: "Fix rate limiter to handle burst traffic correctly",
lastUserPrompt: "The rate limiter lets through 2x the limit when requests arrive simultaneously",
lastAssistantMsg: "Found the race condition in the sliding window counter. Fixed by using atomic increment.",
branch: "main",
sizeBytes: 48200,
},
{
id: "demo-acme-2",
timestamp: now - 4 * hour,
title: "Add pagination to /api/users endpoint",
lastUserPrompt: "Add cursor-based pagination to the users list endpoint",
lastAssistantMsg: "Implemented cursor-based pagination using the user ID as cursor. Added limit param with max 100.",
branch: "main",
sizeBytes: 31400,
},
{
id: "demo-acme-3",
timestamp: now - 2 * day,
title: "Set up OpenTelemetry tracing",
lastUserPrompt: "Configure otel tracing with jaeger exporter for all HTTP handlers",
lastAssistantMsg: "Added @opentelemetry/sdk-node with Jaeger exporter. Wrapped all Hono routes with span creation.",
branch: "feat/observability",
sizeBytes: 67800,
},
],
"quantum-dashboard": [
{
id: "demo-quantum-1",
timestamp: now - 1 * hour,
title: "Build interactive chart tooltip component",
lastUserPrompt: "Create a tooltip that shows data point details on hover with smooth transitions",
lastAssistantMsg: "Created ChartTooltip with Framer Motion transitions. Uses portal to avoid clipping.",
branch: "feat/charts",
sizeBytes: 52100,
},
{
id: "demo-quantum-2",
timestamp: now - 1 * day,
title: "Implement dark mode theme switching",
lastUserPrompt: "Add dark mode toggle that persists in localStorage",
lastAssistantMsg: "Added ThemeProvider with CSS variables approach. Toggle syncs to localStorage and system preference.",
branch: "main",
sizeBytes: 38900,
},
],
"pixel-engine": [
{
id: "demo-pixel-1",
timestamp: now - 3 * hour,
title: "Batch draw calls for sprite rendering",
lastUserPrompt: "Sprite rendering is too slow with individual draw calls, need batching",
lastAssistantMsg: "Implemented instanced rendering with a shared vertex buffer. 60fps stable at 10k sprites now.",
branch: "develop",
sizeBytes: 89300,
},
{
id: "demo-pixel-2",
timestamp: now - 3 * day,
title: "Add tilemap collision detection",
lastUserPrompt: "Need AABB collision against tilemap layers with slope support",
lastAssistantMsg: "Added spatial hash grid for broad phase, then precise AABB vs tile shape. Slopes use SAT.",
branch: "develop",
sizeBytes: 112400,
},
{
id: "demo-pixel-3",
timestamp: now - 5 * day,
title: "Implement ECS architecture",
lastUserPrompt: "Refactor the game objects into an ECS pattern with archetypes",
lastAssistantMsg: "Built archetype-based ECS with sparse sets. Component queries use bitmasked signature matching.",
branch: "feat/ecs",
sizeBytes: 156700,
},
{
id: "demo-pixel-4",
timestamp: now - 8 * day,
title: "WebGPU compute shader for particles",
lastUserPrompt: "Move particle simulation to a compute shader",
lastAssistantMsg: "Particle update runs in compute shader now. 100k particles at 60fps. Uses ping-pong buffers.",
branch: "develop",
sizeBytes: 73200,
},
],
"infractl": [
{
id: "demo-infra-1",
timestamp: now - 2 * day,
title: "Add rollback command for deployments",
lastUserPrompt: "Need a rollback subcommand that reverts to the previous deployment version",
lastAssistantMsg: "Added `infractl rollback` with --revision flag. Tracks deployment history in etcd.",
branch: "main",
sizeBytes: 41200,
},
{
id: "demo-infra-2",
timestamp: now - 6 * day,
title: "Implement health check polling",
lastUserPrompt: "After deploy, poll the health endpoint until ready or timeout",
lastAssistantMsg: "Added exponential backoff health polling with configurable timeout and retries.",
branch: "main",
sizeBytes: 28700,
},
],
"notely-mobile": [
{
id: "demo-notely-1",
timestamp: now - 5 * hour,
title: "Fix offline sync conflict resolution",
lastUserPrompt: "Notes edited offline are overwriting server changes on sync",
lastAssistantMsg: "Switched to CRDT-based merge for note content. Conflicts now auto-resolve with last-write-wins for metadata.",
branch: "release",
sizeBytes: 64500,
},
{
id: "demo-notely-2",
timestamp: now - 1 * day,
title: "Add biometric auth for app lock",
lastUserPrompt: "Implement FaceID/fingerprint unlock when opening the app",
lastAssistantMsg: "Used expo-local-authentication for biometrics. Falls back to PIN. Configurable in settings.",
branch: "feat/security",
sizeBytes: 35800,
},
{
id: "demo-notely-3",
timestamp: now - 4 * day,
title: "Optimize list rendering for large notebooks",
lastUserPrompt: "The notes list stutters when you have 500+ notes",
lastAssistantMsg: "Replaced FlatList with FlashList and added virtualization. Smooth scrolling at 1000 notes.",
branch: "main",
sizeBytes: 29100,
},
],
"blog-engine": [
{
id: "demo-blog-1",
timestamp: now - 5 * day,
title: "Generate RSS feed from MDX posts",
lastUserPrompt: "Add an RSS 2.0 feed at /feed.xml built from the MDX content collection",
lastAssistantMsg: "Added RSS generation in the Astro build pipeline. Extracts frontmatter for title, date, description.",
branch: "main",
sizeBytes: 22300,
},
],
"ml-pipeline": [
{
id: "demo-ml-1",
timestamp: now - 60_000,
title: "Hyperparameter tuning batch 3 results",
lastUserPrompt: "Run the next batch of hyperparameter configs and log results to wandb",
lastAssistantMsg: "Batch 3 complete. Best config: lr=2e-5, warmup=500, dropout=0.1. F1 improved to 0.847.",
branch: "exp/bert",
sizeBytes: 94600,
},
{
id: "demo-ml-2",
timestamp: now - 8 * hour,
title: "Add data augmentation pipeline",
lastUserPrompt: "Implement text augmentation with synonym replacement and back-translation",
lastAssistantMsg: "Added augmentation transforms: synonym swap, back-translation via MarianMT, random insertion.",
branch: "exp/bert",
sizeBytes: 71200,
},
{
id: "demo-ml-3",
timestamp: now - 2 * day,
title: "Set up DVC for dataset versioning",
lastUserPrompt: "Configure DVC with S3 remote for versioning the training datasets",
lastAssistantMsg: "Initialized DVC with S3 backend. Added .dvc files for train/val/test splits. Pipeline stages defined.",
branch: "main",
sizeBytes: 45300,
},
{
id: "demo-ml-4",
timestamp: now - 5 * day,
title: "Implement model evaluation dashboard",
lastUserPrompt: "Create a streamlit dashboard showing model metrics across experiments",
lastAssistantMsg: "Built Streamlit app with confusion matrix, per-class metrics, and experiment comparison charts.",
branch: "main",
sizeBytes: 58100,
},
],
"auth-service": [
{
id: "demo-auth-1",
timestamp: now - 12 * day,
title: "Update JWT refresh token rotation",
lastUserPrompt: "Implement refresh token rotation to prevent token reuse attacks",
lastAssistantMsg: "Added one-time-use refresh tokens with family tracking. Reuse detection invalidates the whole family.",
branch: "main",
sizeBytes: 37400,
},
],
}
const mockBranchData: Record<string, BranchInfo[]> = {
"acme-api": [
{ name: "main", isCurrent: true, lastCommitAge: "2h ago", lastCommitMsg: "fix: rate limiter edge case", ahead: 1, behind: 0 },
{ name: "feat/observability", isCurrent: false, lastCommitAge: "2d ago", lastCommitMsg: "add jaeger tracing config", ahead: 4, behind: 0 },
{ name: "feat/webhooks", isCurrent: false, lastCommitAge: "5d ago", lastCommitMsg: "webhook retry with backoff", ahead: 2, behind: 3 },
],
"quantum-dashboard": [
{ name: "feat/charts", isCurrent: true, lastCommitAge: "5h ago", lastCommitMsg: "add chart tooltip comp", ahead: 3, behind: 0 },
{ name: "main", isCurrent: false, lastCommitAge: "2d ago", lastCommitMsg: "merge: auth flow redesign", ahead: 0, behind: 0 },
{ name: "fix/memory-leak", isCurrent: false, lastCommitAge: "4d ago", lastCommitMsg: "dispose chart instances on unmount", ahead: 1, behind: 5 },
],
"pixel-engine": [
{ name: "develop", isCurrent: true, lastCommitAge: "1d ago", lastCommitMsg: "perf: batch draw calls", ahead: 0, behind: 0 },
{ name: "feat/ecs", isCurrent: false, lastCommitAge: "5d ago", lastCommitMsg: "archetype-based ECS impl", ahead: 12, behind: 2 },
{ name: "main", isCurrent: false, lastCommitAge: "10d ago", lastCommitMsg: "v0.3.0 release", ahead: 0, behind: 0 },
],
"infractl": [
{ name: "main", isCurrent: true, lastCommitAge: "3d ago", lastCommitMsg: "docs: update deploy guide", ahead: 0, behind: 2 },
{ name: "feat/canary", isCurrent: false, lastCommitAge: "8d ago", lastCommitMsg: "canary deploy strategy WIP", ahead: 3, behind: 4 },
],
"notely-mobile": [
{ name: "release", isCurrent: true, lastCommitAge: "6h ago", lastCommitMsg: "bump version to 2.4.1", ahead: 0, behind: 0 },
{ name: "feat/security", isCurrent: false, lastCommitAge: "1d ago", lastCommitMsg: "biometric auth integration", ahead: 5, behind: 0 },
{ name: "main", isCurrent: false, lastCommitAge: "3d ago", lastCommitMsg: "merge: offline sync fixes", ahead: 0, behind: 0 },
],
"blog-engine": [
{ name: "main", isCurrent: true, lastCommitAge: "7d ago", lastCommitMsg: "feat: RSS feed generation", ahead: 0, behind: 0 },
{ name: "feat/search", isCurrent: false, lastCommitAge: "12d ago", lastCommitMsg: "pagefind integration WIP", ahead: 2, behind: 1 },
],
"ml-pipeline": [
{ name: "exp/bert", isCurrent: true, lastCommitAge: "12h ago", lastCommitMsg: "tune hyperparams batch 3", ahead: 7, behind: 0 },
{ name: "main", isCurrent: false, lastCommitAge: "3d ago", lastCommitMsg: "merge: DVC setup", ahead: 0, behind: 0 },
{ name: "exp/t5", isCurrent: false, lastCommitAge: "6d ago", lastCommitMsg: "T5-small baseline results", ahead: 5, behind: 3 },
],
"auth-service": [
{ name: "main", isCurrent: true, lastCommitAge: "14d ago", lastCommitMsg: "chore: dep updates", ahead: -1, behind: -1 },
],
}
export function generateMockSessions(projectPath: string): SessionInfo[] {
const name = projectPath.split("/").pop() || ""
return mockSessionData[name] || []
}
export function generateMockBranches(projectPath: string): BranchInfo[] {
const name = projectPath.split("/").pop() || ""
return mockBranchData[name] || []
}

116
src/data/sessions.ts Normal file
View File

@@ -0,0 +1,116 @@
import { readdirSync } from "node:fs"
import { join } from "node:path"
import type { SessionInfo } from "../lib/types"
const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects`
export async function loadSessions(projectPath: string): Promise<SessionInfo[]> {
const dirName = projectPath.replaceAll("/", "-")
const projDir = join(PROJECTS_DIR, dirName)
let files: string[]
try {
files = readdirSync(projDir).filter((f) => f.endsWith(".jsonl"))
} catch {
return []
}
const sessions = await Promise.all(
files.map((f) => extractSessionInfo(join(projDir, f), f.replace(".jsonl", "")))
)
return sessions
.filter((s): s is SessionInfo => s !== null)
.sort((a, b) => b.timestamp - a.timestamp)
}
async function extractSessionInfo(
filePath: string,
sessionId: string
): Promise<SessionInfo | null> {
try {
const file = Bun.file(filePath)
const size = file.size
if (size === 0) return null
const headSize = Math.min(size, 15 * 1024)
const headText = await file.slice(0, headSize).text()
const headLines = headText.split("\n")
const tailSize = Math.min(size, 60 * 1024)
const tailText = await file.slice(Math.max(0, size - tailSize), size).text()
const tailLines = tailText.split("\n")
if (tailSize < size) tailLines.shift()
let title = ""
let firstTimestamp = 0
let branch = ""
for (const line of headLines) {
if (!line.trim()) continue
try {
const d = JSON.parse(line)
if (!firstTimestamp && d.timestamp) {
firstTimestamp = new Date(d.timestamp).getTime()
}
if (!branch && d.gitBranch) branch = d.gitBranch
if (d.type === "user" && typeof d.message?.content === "string") {
title = d.message.content
break
}
} catch {}
}
let lastUserPrompt = ""
let lastAssistantMsg = ""
let lastTimestamp = 0
for (let i = tailLines.length - 1; i >= 0; i--) {
if (lastUserPrompt && lastAssistantMsg) break
const line = tailLines[i]
if (!line.trim()) continue
try {
const d = JSON.parse(line)
if (d.timestamp) {
const ts = new Date(d.timestamp).getTime()
if (ts > lastTimestamp) lastTimestamp = ts
}
if (
!lastUserPrompt &&
d.type === "user" &&
typeof d.message?.content === "string"
) {
lastUserPrompt = d.message.content
}
if (!lastAssistantMsg && d.type === "assistant" && Array.isArray(d.message?.content)) {
for (const block of d.message.content) {
if (block.type === "text" && block.text) {
lastAssistantMsg = block.text
break
}
}
}
} catch {}
}
if (!title && !lastUserPrompt) return null
return {
id: sessionId,
timestamp: lastTimestamp || firstTimestamp,
title: cleanText(title || lastUserPrompt, 120),
lastUserPrompt: cleanText(lastUserPrompt, 300),
lastAssistantMsg: cleanText(lastAssistantMsg, 300),
branch,
sizeBytes: size,
}
} catch {
return null
}
}
function cleanText(text: string, maxLen: number): string {
const cleaned = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim()
if (cleaned.length > maxLen) return cleaned.slice(0, maxLen - 3) + "..."
return cleaned
}

605
src/index.ts Normal file
View File

@@ -0,0 +1,605 @@
import {
createCliRenderer,
Box,
Text,
BoxRenderable,
TextRenderable,
ScrollBoxRenderable,
t,
bold,
dim,
fg,
green,
yellow,
cyan,
magenta,
type KeyEvent,
type CliRenderer,
} from "@opentui/core"
import { discoverProjects } from "./data/history"
import { loadGitMetadata, loadBranches } from "./data/git"
import { loadSessions } from "./data/sessions"
import { generateMockProjects, generateMockSessions, generateMockBranches } from "./data/mock"
import { launchSelections } from "./actions/launcher"
import type { Project, DisplayRow } from "./lib/types"
import { timeAgo, formatSize } from "./lib/time"
// ─── Theme ──────────────────────────────────────────────────────────
const CURSOR_BG = "#283457"
const ACCENT = "#7aa2f7"
const DIM_CLR = "#565f89"
// ─── State ──────────────────────────────────────────────────────────
const demoMode = Bun.argv.includes("--demo")
let projects: Project[] = []
const selectedProjects = new Set<string>()
const selectedSessions = new Set<string>()
const selectedBranches = new Map<string, string>()
let cursor = 0
let sortMode = 0
const sortLabels = ["recent", "name", "commit", "sessions"]
let sortedIndices: number[] = []
let displayRows: DisplayRow[] = []
// ─── UI Refs ────────────────────────────────────────────────────────
let renderer: CliRenderer
let headerText: TextRenderable
let colHeaderText: TextRenderable
let listBox: ScrollBoxRenderable
let previewText: TextRenderable
let footerText: TextRenderable
// ─── Display Rows ───────────────────────────────────────────────────
function rebuildDisplayRows() {
displayRows = []
for (const idx of sortedIndices) {
const project = projects[idx]
displayRows.push({ type: "project", projectIndex: idx })
if (project.expanded) {
if (project.branches) {
for (const br of project.branches) {
if (!br.isCurrent) {
displayRows.push({ type: "branch", projectIndex: idx, branchName: br.name })
}
}
}
if (project.sessions) {
for (let si = 0; si < project.sessions.length; si++) {
displayRows.push({ type: "session", projectIndex: idx, sessionIndex: si })
}
}
displayRows.push({ type: "new-session", projectIndex: idx })
}
}
}
// ─── Sort ───────────────────────────────────────────────────────────
function applySortMode() {
const indices = Array.from(projects.keys())
switch (sortMode) {
case 0:
sortedIndices = indices
break
case 1:
sortedIndices = indices.sort((a, b) =>
projects[a].name.localeCompare(projects[b].name)
)
break
case 2:
sortedIndices = indices.sort(
(a, b) => (projects[b].commitEpoch || 0) - (projects[a].commitEpoch || 0)
)
break
case 3:
sortedIndices = indices.sort(
(a, b) => projects[b].sessionCount - projects[a].sessionCount
)
break
}
rebuildDisplayRows()
}
// ─── Row Formatting ─────────────────────────────────────────────────
function fmtSyncIndicator(ahead: number, behind: number): string {
if (ahead === -1 && behind === -1) return "✗"
if (ahead === 0 && behind === 0) return "✓"
const parts: string[] = []
if (ahead > 0) parts.push(`${ahead}`)
if (behind > 0) parts.push(`${behind}`)
return parts.join("")
}
function fmtProjectRow(project: Project, isSelected: boolean) {
const check = isSelected ? green("✓") : " "
const arrow = project.expanded ? "▼" : "▶"
const name =
project.name.length > 28 ? project.name.slice(0, 25) + "..." : project.name
const branch =
project.branch.length > 8
? project.branch.slice(0, 7) + "…"
: project.branch
const sync = fmtSyncIndicator(project.ahead, project.behind)
const syncCol = sync === "✓" ? green(sync.padEnd(5))
: sync === "✗" ? dim(sync.padEnd(5))
: yellow(sync.padEnd(5))
const dirtyCol = project.dirty
? yellow(project.dirty.padEnd(9))
: green("clean".padEnd(9))
const ca = project.claudeAgo
let claudeCol
if (ca === "never" || ca === "-") claudeCol = dim(ca.padEnd(9))
else if (ca.includes("m ago") || ca.includes("h ago") || ca === "just now")
claudeCol = cyan(ca.padEnd(9))
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(
(project.commitAge || "-").padEnd(10)
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
String(project.sessionCount).padStart(3)
)} ${dim(String(project.totalMessages).padStart(5))} ${dim(project.tags)}`
}
function fmtSessionRow(
projectIdx: number,
sessionIdx: number,
isSelected: boolean,
isLastSession: boolean
) {
const project = projects[projectIdx]
const session = project.sessions![sessionIdx]
const check = isSelected ? green("✓") : " "
const prefix = isLastSession ? "│ " : "├─"
const title =
session.title.length > 55
? session.title.slice(0, 52) + "..."
: session.title
const age = timeAgo(session.timestamp)
const size = formatSize(session.sizeBytes)
const promptText = session.lastUserPrompt
? session.lastUserPrompt.length > 60
? session.lastUserPrompt.slice(0, 57) + "..."
: session.lastUserPrompt
: "(no text)"
const responseText = session.lastAssistantMsg
? session.lastAssistantMsg.length > 60
? session.lastAssistantMsg.slice(0, 57) + "..."
: session.lastAssistantMsg
: "(no text response)"
return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
size.padEnd(7)
)} ${fg(ACCENT)('"' + title + '"')}
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
}
function fmtNewSessionRow(projectIdx: number, isSelected: boolean) {
const check = isSelected ? green("✓") : " "
return t` ${dim("└─")} [${check}] ${green("+ New session")}`
}
function fmtBranchRow(projectIdx: number, branchName: string, isSelected: boolean) {
const project = projects[projectIdx]
const br = project.branches?.find(b => b.name === branchName)
if (!br) return t` ${dim("├─")} ${branchName}`
const check = isSelected ? green("✓") : " "
const sync = fmtSyncIndicator(br.ahead, br.behind)
const syncCol = sync === "✓" ? green(sync)
: sync === "✗" ? dim(sync)
: yellow(sync)
const msg = br.lastCommitMsg.length > 40 ? br.lastCommitMsg.slice(0, 37) + "..." : br.lastCommitMsg
return t` ${dim("├─")} [${check}] ${magenta(branchName.padEnd(18))} ${syncCol} ${dim(br.lastCommitAge.padEnd(9))} ${dim(msg)}`
}
// ─── UI Updates ─────────────────────────────────────────────────────
function updateHeader() {
const total = selectedProjects.size + selectedSessions.size
const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : ""
const modeLabel = demoMode ? " [DEMO]" : ""
headerText.content = t` ${bold("cladm")}${yellow(modeLabel)}${String(total)} selected${branchNote} ${dim(
`sort: ${sortLabels[sortMode]}${projects.length} projects`
)}`
}
function updateColumnHeaders() {
const cols = ` ${"PROJECT".padEnd(30)} ${"BRANCH".padEnd(9)}${"SYNC".padEnd(5)}${"COMMIT".padEnd(10)}${"MESSAGE".padEnd(22)}${"DIRTY".padEnd(9)}${"LAST USE".padEnd(9)}${"SES".padStart(3)} ${"MSGS".padStart(5)} STACK`
colHeaderText.content = t` ${dim(cols)}`
}
function updateFooter() {
footerText.content = t` ${dim(
"↑↓ nav │ space select │ → expand │ ← collapse │ a all │ n none │ s sort │ enter launch │ q quit"
)}`
}
function updatePreview() {
if (cursor >= displayRows.length) {
previewText.content = t`${dim(" No selection")}`
return
}
const row = displayRows[cursor]
const project = projects[row.projectIndex]
if (row.type === "project") {
previewText.content = t` ${bold(project.name)} ${dim(project.path)}
${dim("Branch:")} ${magenta(project.branch)} ${dim("Commit:")} ${
project.commitAge || "-"
}${project.commitMsg || "-"}
${dim("Status:")} ${project.dirty ? yellow(project.dirty) : green("clean")} ${dim(
"Sessions:"
)} ${String(project.sessionCount)} ${dim("Msgs:")} ${String(project.totalMessages)} ${dim(
"Stack:"
)} ${project.tags || "-"}`
} else if (row.type === "session" && project.sessions) {
const s = project.sessions[row.sessionIndex!]
previewText.content = t` ${bold("Session:")} ${s.title}
${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)")}`
} else if (row.type === "branch" && project.branches) {
const br = project.branches.find(b => b.name === row.branchName)
if (br) {
const sync = fmtSyncIndicator(br.ahead, br.behind)
const selBranch = selectedBranches.get(project.path)
const selNote = selBranch === br.name
? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}`
: t` ${dim("Press space to select this branch for launch")}`
previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync}
${dim("Last commit:")} ${br.lastCommitAge}${br.lastCommitMsg}
${selNote}`
}
} else {
previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}
${dim(project.path)}`
}
}
function rebuildList() {
for (const child of listBox.getChildren()) {
listBox.remove(child.id)
}
for (let i = 0; i < displayRows.length; i++) {
const row = displayRows[i]
const isCursor = i === cursor
const project = projects[row.projectIndex]
let content: ReturnType<typeof t>
let rowHeight = 1
if (row.type === "project") {
const isSel = selectedProjects.has(project.path)
content = fmtProjectRow(project, isSel)
} else if (row.type === "session") {
const session = project.sessions![row.sessionIndex!]
const isSel = selectedSessions.has(session.id)
content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false)
rowHeight = 3
} else if (row.type === "branch") {
const isSel = selectedBranches.get(project.path) === row.branchName
content = fmtBranchRow(row.projectIndex, row.branchName!, isSel)
} else {
const isSel = selectedProjects.has(project.path)
content = fmtNewSessionRow(row.projectIndex, isSel)
}
if (isCursor) {
listBox.add(
Box(
{
backgroundColor: CURSOR_BG,
shouldFill: true,
width: "100%",
height: rowHeight,
},
Text({ content })
)
)
} else {
listBox.add(Text({ content, width: "100%", height: rowHeight }))
}
}
ensureCursorVisible()
renderer.requestRender()
}
function ensureCursorVisible() {
const vpH = listBox.viewport.height
if (vpH <= 0) return
let cursorY = 0
let cursorH = 1
for (let i = 0; i < displayRows.length; i++) {
const h = displayRows[i].type === "session" ? 3 : 1
if (i === cursor) {
cursorH = h
break
}
cursorY += h
}
const top = listBox.scrollTop
if (cursorY < top) {
listBox.scrollTo(cursorY)
} else if (cursorY + cursorH > top + vpH) {
listBox.scrollTo(cursorY + cursorH - vpH)
}
}
function updateAll() {
updateHeader()
rebuildList()
updatePreview()
}
// ─── Keyboard ───────────────────────────────────────────────────────
function handleKeypress(key: KeyEvent) {
const total = displayRows.length
if (total === 0) return
switch (key.name) {
case "up":
if (cursor > 0) cursor--
break
case "down":
if (cursor < total - 1) cursor++
break
case "pageup":
cursor = Math.max(0, cursor - 15)
break
case "pagedown":
cursor = Math.min(total - 1, cursor + 15)
break
case "home":
cursor = 0
break
case "end":
cursor = total - 1
break
case "right": {
const row = displayRows[cursor]
if (row.type === "project") {
const project = projects[row.projectIndex]
if (!project.expanded) {
expandProject(row.projectIndex)
return
}
}
return
}
case "left": {
const row = displayRows[cursor]
if (row.type === "project") {
projects[row.projectIndex].expanded = false
} else {
projects[row.projectIndex].expanded = false
const target = row.projectIndex
rebuildDisplayRows()
cursor = displayRows.findIndex(
(r) => r.type === "project" && r.projectIndex === target
)
if (cursor < 0) cursor = 0
}
rebuildDisplayRows()
if (cursor >= displayRows.length) cursor = displayRows.length - 1
break
}
case "space": {
const row = displayRows[cursor]
if (row.type === "project" || row.type === "new-session") {
const path = projects[row.projectIndex].path
if (selectedProjects.has(path)) selectedProjects.delete(path)
else selectedProjects.add(path)
} else if (row.type === "session") {
const session = projects[row.projectIndex].sessions![row.sessionIndex!]
if (selectedSessions.has(session.id)) selectedSessions.delete(session.id)
else selectedSessions.add(session.id)
} else if (row.type === "branch") {
const path = projects[row.projectIndex].path
if (selectedBranches.get(path) === row.branchName) {
selectedBranches.delete(path)
} else {
selectedBranches.set(path, row.branchName!)
}
}
if (cursor < total - 1) cursor++
break
}
case "a":
for (const p of projects) selectedProjects.add(p.path)
break
case "n":
selectedProjects.clear()
selectedSessions.clear()
selectedBranches.clear()
break
case "s":
sortMode = (sortMode + 1) % sortLabels.length
applySortMode()
cursor = 0
break
case "return":
doLaunch()
return
case "q":
case "escape":
renderer.destroy()
return
default:
return
}
updateAll()
}
async function expandProject(projectIndex: number) {
const project = projects[projectIndex]
if (demoMode) {
if (!project.sessions) {
project.sessions = generateMockSessions(project.path)
project.sessionCount = project.sessions.length
}
if (!project.branches) {
project.branches = generateMockBranches(project.path)
}
} else {
const loads: Promise<void>[] = []
if (!project.sessions) {
loads.push(
loadSessions(project.path).then(s => {
project.sessions = s
project.sessionCount = s.length
})
)
}
if (!project.branches) {
loads.push(
loadBranches(project.path).then(b => { project.branches = b }).catch(() => { project.branches = [] })
)
}
if (loads.length > 0) await Promise.all(loads)
}
project.expanded = true
rebuildDisplayRows()
updateAll()
}
async function doLaunch() {
if (selectedProjects.size === 0 && selectedSessions.size === 0) return
if (demoMode) {
const total = selectedProjects.size + selectedSessions.size
renderer.destroy()
console.log(`[Demo] Would launch ${total} session(s). Launch disabled in demo mode.`)
return
}
renderer.destroy()
const total = selectedProjects.size + selectedSessions.size
console.log(`Launching ${total} session(s)...`)
const count = await launchSelections(projects, selectedProjects, selectedSessions, selectedBranches)
console.log(`Done! ${count} terminal(s) launched.`)
}
// ─── Main ───────────────────────────────────────────────────────────
async function main() {
process.stdout.write("\x1b[2J\x1b[H")
process.stdout.write("\x1b[1m cladm\x1b[0m\n")
if (demoMode) {
process.stdout.write("\x1b[2m [Demo mode] Loading mock projects...\x1b[0m\n")
projects = generateMockProjects()
} else {
process.stdout.write("\x1b[2m Loading projects...\x1b[0m\n")
projects = await discoverProjects()
if (projects.length === 0) {
console.log(" No projects found in ~/.claude/history.jsonl")
process.exit(1)
}
process.stdout.write(
`\x1b[2m Found ${projects.length} projects. Loading git metadata...\x1b[0m\n`
)
await Promise.all(projects.map((p) => loadGitMetadata(p)))
}
sortedIndices = projects.map((_, i) => i)
rebuildDisplayRows()
renderer = await createCliRenderer({
exitOnCtrlC: true,
useAlternateScreen: true,
useMouse: true,
})
// Build layout
const mainBox = new BoxRenderable(renderer, {
flexDirection: "column",
width: "100%",
height: "100%",
})
headerText = new TextRenderable(renderer, {
width: "100%",
height: 1,
flexShrink: 0,
})
colHeaderText = new TextRenderable(renderer, {
width: "100%",
height: 1,
flexShrink: 0,
})
listBox = new ScrollBoxRenderable(renderer, {
scrollY: true,
flexGrow: 1,
viewportCulling: true,
})
const previewBox = new BoxRenderable(renderer, {
height: 7,
flexShrink: 0,
width: "100%",
borderStyle: "single",
border: ["top"],
borderColor: DIM_CLR,
title: " Preview ",
titleAlignment: "left",
flexDirection: "column",
paddingLeft: 0,
})
previewText = new TextRenderable(renderer, {
width: "100%",
flexGrow: 1,
wrapMode: "word",
})
previewBox.add(previewText)
footerText = new TextRenderable(renderer, {
width: "100%",
height: 1,
flexShrink: 0,
})
mainBox.add(headerText)
mainBox.add(colHeaderText)
mainBox.add(listBox)
mainBox.add(previewBox)
mainBox.add(footerText)
renderer.root.add(mainBox)
updateHeader()
updateColumnHeaders()
rebuildList()
updatePreview()
updateFooter()
renderer.keyInput.on("keypress", handleKeypress)
}
main().catch((err) => {
console.error("Fatal:", err)
process.exit(1)
})

15
src/lib/tags.ts Normal file
View File

@@ -0,0 +1,15 @@
import { existsSync } from "node:fs"
import { join } from "node:path"
export function getTags(dir: string): string {
const tags: string[] = []
const has = (f: string) => existsSync(join(dir, f))
if (has(".git")) tags.push("git")
if (has("package.json")) tags.push("node")
if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) tags.push("py")
if (has("Cargo.toml")) tags.push("rust")
if (has("go.mod")) tags.push("go")
if (has("CLAUDE.md")) tags.push("claude")
if (has("Dockerfile") || has("docker-compose.yml") || has("docker-compose.yaml")) tags.push("docker")
return tags.join(",")
}

17
src/lib/time.ts Normal file
View File

@@ -0,0 +1,17 @@
export function timeAgo(ms: number): string {
if (!ms) return "never"
const diff = Math.floor((Date.now() - ms) / 1000)
if (diff < 0) return "just now"
if (diff < 60) return "just now"
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`
return `${Math.floor(diff / 2592000)}mo ago`
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}

45
src/lib/types.ts Normal file
View File

@@ -0,0 +1,45 @@
export interface BranchInfo {
name: string
isCurrent: boolean
lastCommitAge: string
lastCommitMsg: string
ahead: number
behind: number
}
export interface Project {
path: string
name: string
branch: string
commitAge: string
commitMsg: string
commitEpoch: number
dirty: string
ahead: number
behind: number
claudeAgo: string
claudeLastMs: number
sessionCount: number
totalMessages: number
tags: string
expanded: boolean
sessions: SessionInfo[] | null
branches: BranchInfo[] | null
}
export interface SessionInfo {
id: string
timestamp: number
title: string
lastUserPrompt: string
lastAssistantMsg: string
branch: string
sizeBytes: number
}
export interface DisplayRow {
type: "project" | "session" | "new-session" | "branch"
projectIndex: number
sessionIndex?: number
branchName?: string
}