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:
97
src/actions/launcher.ts
Normal file
97
src/actions/launcher.ts
Normal 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
127
src/data/git.ts
Normal 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
75
src/data/history.ts
Normal 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
410
src/data/mock.ts
Normal 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
116
src/data/sessions.ts
Normal 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
605
src/index.ts
Normal 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
15
src/lib/tags.ts
Normal 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
17
src/lib/time.ts
Normal 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
45
src/lib/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user