diff --git a/src/actions/launch.ts b/src/actions/launch.ts new file mode 100644 index 0000000..30e0678 --- /dev/null +++ b/src/actions/launch.ts @@ -0,0 +1,71 @@ +import { app } from "../lib/state" +import { updateAll, rebuildDisplayRows } from "../ui/panels" +import { ensureGridView } from "../grid/view-switch" +import { loadSessions } from "../data/sessions" +import { createSession } from "../pty/session-manager" + +export async function doLaunch() { + if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return + if (app.demoMode) { + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + rebuildDisplayRows() + updateAll() + return + } + + const items: { path: string; name: string; sessionId?: string; targetBranch?: string }[] = [] + + for (const path of app.selectedProjects) { + const project = app.projects.find(p => p.path === path) + if (!project) continue + const targetBranch = app.selectedBranches.get(path) + const needsBranch = targetBranch && targetBranch !== project.branch + if (!project.sessions) { + project.sessions = await loadSessions(project.path) + project.sessionCount = project.sessions.length + } + const lastSessionId = project.sessions[0]?.id + items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) + } + + for (const project of app.projects) { + if (!project.sessions) continue + for (const session of project.sessions) { + if (app.selectedSessions.has(session.id)) { + const targetBranch = app.selectedBranches.get(project.path) + const needsBranch = targetBranch && targetBranch !== project.branch + items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) + } + } + } + + if (items.length === 0) return + + ensureGridView() + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const totalPanes = items.length + (app.directGrid?.paneCount || 0) + const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 + const rows = Math.ceil(totalPanes / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const item of items) { + const session = await createSession({ + projectPath: item.path, + projectName: item.name, + sessionId: item.sessionId, + targetBranch: item.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid!.addPane(session) + } + + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() +} diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts new file mode 100644 index 0000000..88024f1 --- /dev/null +++ b/src/grid/view-switch.ts @@ -0,0 +1,29 @@ +import { app } from "../lib/state" +import { DirectGridRenderer } from "../components/direct-grid" + +export function ensureGridView() { + if (app.viewMode === "grid" && app.directGrid) return + switchToGrid() +} + +export function switchToGrid() { + app.viewMode = "grid" + + if (!app.directGrid) { + app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) + } + + app.renderer.suspend() + + if (process.stdin.isTTY) process.stdin.setRawMode(true) + process.stdin.resume() + app.rawStdoutWrite("\x1b[?1049h") + app.rawStdoutWrite("\x1b[?1000h") + app.rawStdoutWrite("\x1b[?1006h") + app.directGrid.start() +} + +export function resizeGridPanes() { + if (!app.directGrid || app.directGrid.paneCount === 0) return + app.directGrid.repositionAll() +} diff --git a/src/index.ts b/src/index.ts index 468fd65..c6b2ebf 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,1091 +1,99 @@ #!/usr/bin/env bun 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 { loadGitMetadata } from "./data/git" import { loadSessions } from "./data/sessions" -import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock" -import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor" -import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage" -import { launchSelections } from "./actions/launcher" -import { createSession, getSessions, refreshAlive, type PtySession } from "./pty/session-manager" +import { generateMockProjects, generateMockSessions, generateMockBusySessions } from "./data/mock" +import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus } from "./data/monitor" +import { getUsageSummary } from "./data/usage" +import { getSessions, refreshAlive } from "./pty/session-manager" import { stopAllCaptures } from "./pty/capture" -import { DirectGridRenderer } from "./components/direct-grid" -import type { Project, DisplayRow } from "./lib/types" -import { timeAgo, formatSize, elapsedCompact } from "./lib/time" +import { DIM_CLR } from "./lib/theme" +import { app } from "./lib/state" +import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels" +import { stdinHandler } from "./input/handlers" +import { resizeGridPanes } from "./grid/view-switch" -// ─── Theme ────────────────────────────────────────────────────────── -const CURSOR_BG = "#283457" -const ACTIVE_BG = "#1a2e1a" -const ACCENT = "#7aa2f7" -const DIM_CLR = "#565f89" - -// ─── State ────────────────────────────────────────────────────────── -const demoMode = Bun.argv.includes("--demo") -let projects: Project[] = [] -const selectedProjects = new Set() -const selectedSessions = new Set() -const selectedBranches = new Map() -let cursor = 0 -let sortMode = 0 -const sortLabels = ["recent", "name", "commit", "sessions"] -let sortedIndices: number[] = [] -let displayRows: DisplayRow[] = [] -let monitorInterval: ReturnType | null = null -let prevBusySnapshot: Map = new Map() -let bottomPanelMode: "preview" | "idle" = "preview" -let destroyed = false -let idleCursor = 0 -let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = [] - -// ─── Grid Mode State ─────────────────────────────────────────────── -type ViewMode = "picker" | "grid" -let viewMode: ViewMode = "picker" -let directGrid: DirectGridRenderer | null = null -let mainBox: BoxRenderable | null = null -let rawStdoutWrite: (s: string) => boolean - -// ─── UI Refs ──────────────────────────────────────────────────────── -let renderer: CliRenderer -let headerText: TextRenderable -let colHeaderText: TextRenderable -let listBox: ScrollBoxRenderable -let bottomRow: BoxRenderable -let previewBox: BoxRenderable -let previewText: TextRenderable -let usageBox: BoxRenderable -let footerText: TextRenderable -let cachedUsage: UsageSummary | null = null - -// ─── 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) { - let activeDot: string - let activeTag: string - if (project.activeSessions > 0) { - if (project.busySessions > 0) { - activeDot = green("●") - const count = String(project.activeSessions) - activeTag = project.activeSessions > 1 ? yellow((count + " ").slice(0, 2)) : " " - } else { - activeDot = yellow("◉") - const elapsed = elapsedCompact(project.lastActivityMs) - activeTag = elapsed ? dim((elapsed + " ").slice(0, 2)) : " " - } - } else { - activeDot = dim("○") - activeTag = " " - } - 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` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( - (project.commitAge || "-").padEnd(10) - )}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim( - String(project.sessionCount).padStart(3) - )} ${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 status = getSessionStatus(project.path, session.id) - - 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)" - - if (status === "busy") { - return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( - size.padEnd(7) - )} ${fg(ACCENT)('"' + title + '"')} ${green("running")} - ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} - ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` - } - if (status === "idle") { - return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( - size.padEnd(7) - )} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")} - ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} - ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` - } - 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]" : "" - const activeCount = projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) - const busyCount = projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) - const idleCount = activeCount - busyCount - if (activeCount > 0) { - headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( - `sort: ${sortLabels[sortMode]} │ ${projects.length} projects` - )} │ ${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}` - } else { - headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( - `sort: ${sortLabels[sortMode]} │ ${projects.length} projects` - )}` - } -} - -function updateColumnHeaders() { - 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 addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) { - const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6) - const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName - const title = s.sessionTitle.length > 50 ? s.sessionTitle.slice(0, 47) + "..." : s.sessionTitle - const prompt = s.lastPrompt - ? s.lastPrompt.length > 60 ? s.lastPrompt.slice(0, 57) + "..." : s.lastPrompt - : "(no text)" - const response = s.lastResponse - ? s.lastResponse.length > 60 ? s.lastResponse.slice(0, 57) + "..." : s.lastResponse - : "(no response)" - const pointer = isCursor ? "▸" : " " - previewBox.add(Text({ content: t` ${yellow("◉")} ${isCursor ? cyan(pointer) : dim(pointer)} ${dim(elapsed)}${bold(name)} ${fg(ACCENT)('"' + title + '"')}`, width: "100%", height: 1 })) - previewBox.add(Text({ content: t` ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + prompt + '"')}`, width: "100%", height: 1 })) - previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 })) -} - -function updateIdlePanel() { - cachedIdleSessions = getIdleSessions(projects) - const n = cachedIdleSessions.length - previewBox.title = ` Idle Sessions (${n}) — enter to focus ` - // Clear all children and rebuild - for (const child of previewBox.getChildren()) previewBox.remove(child.id) - if (n === 0) { - idleCursor = 0 - previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) - return - } - if (idleCursor >= n) idleCursor = n - 1 - const show = cachedIdleSessions.slice(0, 3) - for (let i = 0; i < show.length; i++) { - addIdleRow(show[i], idleCursor === i) - } - if (n > 3) { - previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 })) - } -} - -function updateBottomPanel() { - if (bottomPanelMode === "idle") { - bottomRow.height = 14 - updateIdlePanel() - } else { - // Restore previewText as sole child - for (const child of previewBox.getChildren()) previewBox.remove(child.id) - previewBox.add(previewText) - bottomRow.height = 10 - previewBox.title = " Preview " - updatePreview() - } -} - -function usageBarColor(p: number) { - return p >= 80 ? yellow : p >= 50 ? cyan : green -} - -function updateUsagePanel() { - if (destroyed) return - for (const child of usageBox.getChildren()) usageBox.remove(child.id) - - if (!cachedUsage) { - usageBox.title = " Usage " - usageBox.add(Text({ content: t`${dim("Loading...")}`, width: "100%", height: 1 })) - return - } - - const u = cachedUsage - const BAR_W = 18 - - // ── Current session ── - const sPct = pct(u.totalCost, PLAN_LIMITS.session) - const sBar = makeBar(u.totalCost, PLAN_LIMITS.session, BAR_W) - const sReset = u.sessionResetMs > 0 ? formatWindow(u.sessionResetMs) : "" - usageBox.title = " Usage " - usageBox.add(Text({ content: t`${bold("Session")}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(sPct)(sBar)} ${bold(String(sPct) + "%")}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${dim(sReset ? "resets " + sReset : "")} ${dim(formatCost(u.costPerHour) + "/h")}`, width: "100%", height: 1 })) - - // ── Weekly all models ── - const wPct = pct(u.weekTotal, PLAN_LIMITS.weeklyAll) - const wBar = makeBar(u.weekTotal, PLAN_LIMITS.weeklyAll, BAR_W) - usageBox.add(Text({ content: t`${bold("All models")} ${dim(formatCost(u.weekTotal))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(wPct)(wBar)} ${bold(String(wPct) + "%")}`, width: "100%", height: 1 })) - - // ── Weekly sonnet only ── - const snPct = pct(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet) - const snBar = makeBar(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet, BAR_W) - usageBox.add(Text({ content: t`${bold("Sonnet")} ${dim(formatCost(u.weeklySonnetCost))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(snPct)(snBar)} ${bold(String(snPct) + "%")}`, width: "100%", height: 1 })) - - // ── Monthly total ── - const monthLabel = new Date().toLocaleString("en", { month: "short" }) - usageBox.add(Text({ content: t`${bold(monthLabel + " total")} ${dim(formatCost(u.monthlyTotalCost))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${dim(formatCost(u.costPerHour) + "/h avg · " + u.totalRequests + " reqs")}`, width: "100%", height: 1 })) - - renderer.requestRender() -} - -function updateFooter() { - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) { - footerText.content = t` ${dim( - "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" - )}` - } else { - footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ 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!] - const sStatus = getSessionStatus(project.path, s.id) - const sLabel = sStatus === "busy" ? green(" ● running") : sStatus === "idle" ? yellow(" ◉ idle") : "" - previewText.content = t` ${bold("Session:")} ${s.title}${sLabel} - ${dim(timeAgo(s.timestamp))} · ${dim(formatSize(s.sizeBytes))} · ${magenta(s.branch || "-")} - ${dim("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 - 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) - } - - const isActiveProject = row.type === "project" && project.activeSessions > 0 - const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null - const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined - - if (bgColor) { - listBox.add( - Box( - { - backgroundColor: bgColor, - 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() { - if (destroyed) return - updateHeader() - rebuildList() - updateBottomPanel() - updateFooter() -} - -// ─── Mouse Parsing ────────────────────────────────────────────────── -interface MouseEvent { btn: number; col: number; row: number; release: boolean } - -function parseMouseEvent(seq: string): MouseEvent | null { - // SGR encoding: \x1b[= 6 && seq.startsWith("\x1b[M")) { - const raw = seq.charCodeAt(3) - 32 - const col = seq.charCodeAt(4) - 32 - const row = seq.charCodeAt(5) - 32 - // In legacy mode, btn 3 = release, lower 2 bits = button - const release = (raw & 3) === 3 - const btn = release ? 0 : (raw & 3) - // Scroll: bit 6 set means scroll. 64 = scroll up, 65 = scroll down - const scroll = raw & 64 - return { btn: scroll ? (raw & 67) : btn, col, row, release: release && !scroll } - } - - return null -} - -// ─── Mouse ────────────────────────────────────────────────────────── -function hitTestListRow(screenRow: number): number { - // List starts at row 2 (header=0, colHeader=1) - const listStartY = 2 - const relY = screenRow - listStartY + listBox.scrollTop - if (relY < 0) return -1 - - let y = 0 - for (let i = 0; i < displayRows.length; i++) { - const h = displayRows[i].type === "session" ? 3 : 1 - if (relY >= y && relY < y + h) return i - y += h - } - return -1 -} - -function handlePickerClick(_col: number, screenRow: number) { - const idx = hitTestListRow(screenRow) - if (idx < 0 || idx >= displayRows.length) return - - cursor = idx - const row = displayRows[idx] - const project = projects[row.projectIndex] - - // Toggle selection (same as space key) - if (row.type === "project" || row.type === "new-session") { - const path = project.path - if (selectedProjects.has(path)) selectedProjects.delete(path) - else selectedProjects.add(path) - } else if (row.type === "session") { - const session = project.sessions![row.sessionIndex!] - if (selectedSessions.has(session.id)) selectedSessions.delete(session.id) - else selectedSessions.add(session.id) - } else if (row.type === "branch") { - const path = project.path - if (selectedBranches.get(path) === row.branchName) { - selectedBranches.delete(path) - } else { - selectedBranches.set(path, row.branchName!) - } - } - - updateAll() -} - -// ─── Keyboard ─────────────────────────────────────────────────────── -async function handleKeypress(key: KeyEvent) { - try { - 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!) - } - } - break - } - - case "f": { - const row = displayRows[cursor] - const project = projects[row.projectIndex] - Bun.spawn(["open", project.path]) - break - } - - case "g": { - const row = displayRows[cursor] - const project = projects[row.projectIndex] - if (project.activeSessions > 0) { - const sid = row.type === "session" && project.sessions - ? project.sessions[row.sessionIndex!]?.id - : undefined - await focusTerminalByPath(project.path, sid) - } - return - } - - case "a": - for (const p of projects) selectedProjects.add(p.path) - break - - case "n": - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - break - - case "i": - bottomPanelMode = bottomPanelMode === "preview" ? "idle" : "preview" - idleCursor = 0 - break - - case "tab": - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) { - if (key.shift) { - idleCursor = idleCursor > 0 ? idleCursor - 1 : Math.min(cachedIdleSessions.length, 3) - 1 - } else { - idleCursor = (idleCursor + 1) % Math.min(cachedIdleSessions.length, 3) - } - } - break - - case "s": - sortMode = (sortMode + 1) % sortLabels.length - applySortMode() - cursor = 0 - break - - case "return": { - const hasSelections = selectedProjects.size > 0 || selectedSessions.size > 0 - // If user has checkmarked items, always launch them - if (hasSelections) { - doLaunch() - break - } - // Focus idle session from idle panel (only when nothing selected) - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0 && idleCursor < cachedIdleSessions.length) { - const focused = await focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath) - if (focused) return - } - // If cursor is on a project row with active session, focus it - const returnRow = displayRows[cursor] - if ( - returnRow.type === "project" && - projects[returnRow.projectIndex].activeSessions > 0 - ) { - const focused = await focusTerminalByPath(projects[returnRow.projectIndex].path) - if (focused) return - } - doLaunch() - break - } - - case "o": { - // Open selected projects in external Terminal.app windows - const hasOSel = selectedProjects.size > 0 || selectedSessions.size > 0 - if (!hasOSel) { - // If nothing selected, select current row's project - const oRow = displayRows[cursor] - if (oRow) selectedProjects.add(projects[oRow.projectIndex].path) - } - if (selectedProjects.size > 0 || selectedSessions.size > 0) { - await launchSelections(projects, selectedProjects, selectedSessions, selectedBranches) - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - } - break - } - - case "q": - case "escape": - destroyed = true - if (monitorInterval) clearInterval(monitorInterval) - stopAllCaptures() - process.stdout.write("\x1b[?1006l") - process.stdout.write("\x1b[?1000l") - renderer.destroy() - return - - case "t": - // Switch to grid view if there are panes - if (directGrid && directGrid.paneCount > 0) { - switchToGrid() - return - } - return - - default: - return - } - - updateAll() - } catch {} -} - -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) - } - populateMockSessionStatus(project) - } else { - const loads: Promise[] = [] - 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) { - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - rebuildDisplayRows() - updateAll() - return - } - - // Build launch items - const items: { path: string; name: string; sessionId?: string; targetBranch?: string }[] = [] - - for (const path of selectedProjects) { - const project = projects.find(p => p.path === path) - if (!project) continue - const targetBranch = selectedBranches.get(path) - const needsBranch = targetBranch && targetBranch !== project.branch - // Auto-resume most recent session (loaded lazily if needed) - if (!project.sessions) { - project.sessions = await loadSessions(project.path) - project.sessionCount = project.sessions.length - } - const lastSessionId = project.sessions[0]?.id - items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) - } - - for (const project of projects) { - if (!project.sessions) continue - for (const session of project.sessions) { - if (selectedSessions.has(session.id)) { - const targetBranch = selectedBranches.get(project.path) - const needsBranch = targetBranch && targetBranch !== project.branch - items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) - } - } - } - - if (items.length === 0) return - - // Create PTY sessions and switch to grid view - ensureGridView() - - // DirectGridRenderer calculates pane sizes internally - const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 - const totalPanes = items.length + (directGrid?.paneCount || 0) - const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 - const rows = Math.ceil(totalPanes / cols) - const paneW = Math.max(Math.floor(termW / cols) - 2, 20) - const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) - - for (const item of items) { - const session = await createSession({ - projectPath: item.path, - projectName: item.name, - sessionId: item.sessionId, - targetBranch: item.targetBranch, - width: paneW, - height: paneH, - }) - await directGrid!.addPane(session) - } - - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() -} - -// ─── Grid View (Direct Renderer) ──────────────────────────────────── -function ensureGridView() { - if (viewMode === "grid" && directGrid) return - switchToGrid() -} - -function switchToGrid() { - viewMode = "grid" - - if (!directGrid) { - directGrid = new DirectGridRenderer(rawStdoutWrite) - } - - // Suspend OpenTUI render loop — this pauses stdin, exits raw mode, disables mouse - renderer.suspend() - - // Restore terminal state for direct rendering - if (process.stdin.isTTY) process.stdin.setRawMode(true) - process.stdin.resume() // Re-enable stdin data events (suspend pauses them) - rawStdoutWrite("\x1b[?1049h") // Alternate screen - // Enable mouse reporting in grid mode — only for scroll wheel and click-to-focus. - // With direct PTY (no tmux), mouse events stay in our stdin handler and never leak to subprocesses. - rawStdoutWrite("\x1b[?1000h") // Basic mouse tracking (clicks + scroll) - rawStdoutWrite("\x1b[?1006h") // SGR extended mouse coordinates - directGrid.start() -} - -function switchToPicker() { - viewMode = "picker" - if (directGrid) { - if (directGrid.selectMode) directGrid.exitSelectMode() - if (directGrid.paneCount > 0) directGrid.stop() - } - // Resume OpenTUI — it will re-enter alternate screen and redraw - renderer.resume() - // Remove any data listeners OpenTUI re-adds during resume (we handle stdin ourselves) - process.stdin.removeAllListeners("data") - process.stdin.on("data", stdinHandler) - // Re-enable mouse reporting (resume may not restore it) - process.stdout.write("\x1b[?1000h") - process.stdout.write("\x1b[?1006h") - if (mainBox) mainBox.visible = true - updateAll() - renderer.requestRender() -} - -function resizeGridPanes() { - if (!directGrid || directGrid.paneCount === 0) return - directGrid.repositionAll() -} - -async function handleGridInput(rawSequence: string): Promise { - if (viewMode !== "grid" || !directGrid) return false - - // Esc — collapse expanded pane back to grid - if (rawSequence === "\x1b" && directGrid.isExpanded) { - directGrid.collapsePane() - return true - } - - // Ctrl+` (0x1e) or ESC+` — return to picker - if (rawSequence === "\x1e" || rawSequence === "\x1b`") { - switchToPicker() - return true - } - - // Ctrl+N — focus next pane - if (rawSequence === "\x0e") { - directGrid.focusNext() - return true - } - - // Ctrl+P — focus previous pane - if (rawSequence === "\x10") { - directGrid.focusPrev() - return true - } - - // Ctrl+F — open focused pane's project in Finder - if (rawSequence === "\x06") { - const pane = directGrid.focusedPane - if (pane) Bun.spawn(["open", pane.session.projectPath]) - return true - } - - // Ctrl+W — close focused pane - if (rawSequence === "\x17") { - const pane = directGrid.focusedPane - if (pane) { - if (directGrid.isExpanded) directGrid.collapsePane() - const { killSession } = await import("./pty/session-manager") - directGrid.removePane(pane.session.name) - await killSession(pane.session.name) - if (directGrid.paneCount === 0) { - switchToPicker() - } - } - return true - } - - // PageUp / PageDown (Fn+Up/Down on Mac) — scroll focused pane - if (rawSequence === "\x1b[5~") { - directGrid.sendScrollToFocused("up") - return true - } - if (rawSequence === "\x1b[6~") { - directGrid.sendScrollToFocused("down") - return true - } - - // Forward everything else to the focused PTY pane - directGrid.sendInputToFocused(rawSequence) - return true -} - -// ─── Main ─────────────────────────────────────────────────────────── async function main() { process.stdout.write("\x1b[2J\x1b[H") process.stdout.write("\x1b[1m cladm\x1b[0m\n") - if (demoMode) { + if (app.demoMode) { process.stdout.write("\x1b[2m [Demo mode] Loading mock projects...\x1b[0m\n") - projects = generateMockProjects() + app.projects = generateMockProjects() } else { process.stdout.write("\x1b[2m Loading projects...\x1b[0m\n") - projects = await discoverProjects() - if (projects.length === 0) { + app.projects = await discoverProjects() + if (app.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` + `\x1b[2m Found ${app.projects.length} projects. Loading git metadata...\x1b[0m\n` ) - await Promise.all(projects.map((p) => loadGitMetadata(p))) + await Promise.all(app.projects.map((p) => loadGitMetadata(p))) } - sortedIndices = projects.map((_, i) => i) + app.sortedIndices = app.projects.map((_, i) => i) rebuildDisplayRows() // Save raw stdout.write BEFORE OpenTUI intercepts it - rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean + app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean - renderer = await createCliRenderer({ + app.renderer = await createCliRenderer({ exitOnCtrlC: true, useAlternateScreen: true, - useMouse: false, // We handle mouse ourselves to avoid OpenTUI consuming events + useMouse: false, onDestroy: () => { - destroyed = true - if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null } - if (directGrid) directGrid.destroyAll() + app.destroyed = true + if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null } + if (app.directGrid) app.directGrid.destroyAll() stopAllCaptures() }, }) // Enable mouse reporting manually (SGR mode for full coordinates) - process.stdout.write("\x1b[?1000h") // Basic click tracking - process.stdout.write("\x1b[?1006h") // SGR extended coordinates + process.stdout.write("\x1b[?1000h") + process.stdout.write("\x1b[?1006h") // Build layout - mainBox = new BoxRenderable(renderer, { + app.mainBox = new BoxRenderable(app.renderer, { flexDirection: "column", width: "100%", height: "100%", }) - headerText = new TextRenderable(renderer, { + app.headerText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - colHeaderText = new TextRenderable(renderer, { + app.colHeaderText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - listBox = new ScrollBoxRenderable(renderer, { + app.listBox = new ScrollBoxRenderable(app.renderer, { scrollY: true, flexGrow: 1, viewportCulling: true, }) - bottomRow = new BoxRenderable(renderer, { + app.bottomRow = new BoxRenderable(app.renderer, { flexDirection: "row", height: 10, flexShrink: 0, width: "100%", }) - previewBox = new BoxRenderable(renderer, { + app.previewBox = new BoxRenderable(app.renderer, { flexGrow: 1, height: "100%", borderStyle: "single", @@ -1097,14 +105,14 @@ async function main() { paddingLeft: 0, }) - previewText = new TextRenderable(renderer, { + app.previewText = new TextRenderable(app.renderer, { width: "100%", flexGrow: 1, wrapMode: "word", }) - previewBox.add(previewText) + app.previewBox.add(app.previewText) - usageBox = new BoxRenderable(renderer, { + app.usageBox = new BoxRenderable(app.renderer, { width: 34, height: "100%", flexShrink: 0, @@ -1118,383 +126,119 @@ async function main() { paddingRight: 1, }) - bottomRow.add(previewBox) - bottomRow.add(usageBox) + app.bottomRow.add(app.previewBox) + app.bottomRow.add(app.usageBox) - footerText = new TextRenderable(renderer, { + app.footerText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - mainBox.add(headerText) - mainBox.add(colHeaderText) - mainBox.add(listBox) - mainBox.add(bottomRow) - mainBox.add(footerText) + app.mainBox.add(app.headerText) + app.mainBox.add(app.colHeaderText) + app.mainBox.add(app.listBox) + app.mainBox.add(app.bottomRow) + app.mainBox.add(app.footerText) - renderer.root.add(mainBox) + app.renderer.root.add(app.mainBox) - updateHeader() updateColumnHeaders() - rebuildList() - updateBottomPanel() updateUsagePanel() - updateFooter() + updateAll() // Load initial usage data getUsageSummary().then(u => { - cachedUsage = u + app.cachedUsage = u updateUsagePanel() }).catch(() => {}) // Resize PTY panes when terminal window is resized process.stdout.on("resize", () => { - if (viewMode !== "grid" || !directGrid) return + if (app.viewMode !== "grid" || !app.directGrid) return resizeGridPanes() }) - // Shift+arrow sequences across terminal emulators - const SHIFT_ARROWS: Record = { - "\x1b[1;2A": "up", // xterm/iTerm2/most terminals - "\x1b[1;2B": "down", - "\x1b[1;2C": "right", - "\x1b[1;2D": "left", - "\x1b[a": "up", // rxvt - "\x1b[b": "down", - "\x1b[c": "right", - "\x1b[d": "left", - } - - // Take over stdin completely — removes OpenTUI's data listeners - // We parse all keys ourselves to avoid double-processing issues + // Take over stdin completely process.stdin.removeAllListeners("data") - - // Extract only safe keyboard input from stdin data. - // WHITELIST approach: only recognized keyboard sequences pass through. - // Everything else (mouse events, terminal responses, OSC, DCS, etc.) is dropped. - function extractKeyboardInput(data: string): string { - let keyboard = "" - let i = 0 - - while (i < data.length) { - const c = data.charCodeAt(i) - - // ESC sequences - if (c === 0x1b) { - if (i + 1 >= data.length) { keyboard += "\x1b"; i++; continue } // lone ESC = Escape key - - const next = data[i + 1] - - // OSC: \x1b] ... (terminated by BEL \x07 or ST \x1b\\) — drop entirely - if (next === "]") { - let j = i + 2 - while (j < data.length) { - if (data[j] === "\x07") { j++; break } - if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } - j++ - } - i = j; continue - } - - // DCS: \x1bP ... ST | APC: \x1b_ ... ST | PM: \x1b^ ... ST — drop entirely - if (next === "P" || next === "_" || next === "^") { - let j = i + 2 - while (j < data.length) { - if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } - j++ - } - i = j; continue - } - - // CSI: \x1b[ - if (next === "[") { - let j = i + 2 - // Consume parameter bytes (0x30-0x3F: digits, ;, <, =, >, ?) - while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3F) j++ - // Consume intermediate bytes (0x20-0x2F) - while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2F) j++ - // Final byte (0x40-0x7E) - if (j < data.length && data.charCodeAt(j) >= 0x40 && data.charCodeAt(j) <= 0x7E) { - const final = data[j] - // Legacy X10 mouse: \x1b[M followed by 3 raw bytes (btn+32, col+32, row+32) - // Must consume the 3 payload bytes or they leak as keyboard input - // (btn+32 for left click = 0x20 = ASCII space!) - if (final === "M" && j === i + 2) { - i = Math.min(j + 4, data.length); continue - } - // ONLY keep: arrows (A-D), Home (H), End (F), shift-tab (Z), function keys (~) - if ("ABCDHFZ~".includes(final)) { - keyboard += data.slice(i, j + 1) - } - i = j + 1; continue - } - // Incomplete/malformed CSI — drop - i = j; continue - } - - // SS3: \x1bO + letter (F1-F4, keypad) - if (next === "O" && i + 2 < data.length) { - keyboard += data.slice(i, i + 3) - i += 3; continue - } - - // \x1b` (ctrl+backtick) — keep as keyboard shortcut - if (next === "`") { - keyboard += "\x1b`" - i += 2; continue - } - - // Any other \x1b+char — drop (unknown escape sequence) - i += 2; continue - } - - // Regular character: printable ASCII, control chars, UTF-8 — keep - keyboard += data[i] - i++ - } - - return keyboard - } - - // Parse SGR mouse events from raw data. Returns array of {btn, col, row, release, consumed}. - function extractMouseEvents(data: string): { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] { - const events: { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] = [] - const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g - let m - while ((m = re.exec(data)) !== null) { - events.push({ - btn: parseInt(m[1]), - col: parseInt(m[2]), - row: parseInt(m[3]), - release: m[4] === "m", - start: m.index, - end: m.index + m[0].length, - }) - } - return events - } - - function stdinHandler(data: string | Buffer) { - const str = typeof data === "string" ? data : data.toString("utf8") - - if (viewMode === "grid" && directGrid) { - // In select mode, only listen for Esc to exit — everything else is native terminal - if (directGrid.selectMode) { - const keyboard = extractKeyboardInput(str) - if (keyboard === "\x1b") { - directGrid.exitSelectMode() - } - return - } - - // Handle mouse events first (scroll wheel + click-to-focus) - const mouseEvents = extractMouseEvents(str) - for (const me of mouseEvents) { - // Scroll: btn 64 = scroll up, btn 65 = scroll down - if (me.btn === 64) { - directGrid.sendScrollToFocused("up", 3) - continue - } - if (me.btn === 65) { - directGrid.sendScrollToFocused("down", 3) - continue - } - // Click (btn 0, press only): check buttons first, then focus pane - if (me.btn === 0 && !me.release) { - const btn = directGrid.checkButtonClick(me.col, me.row) - if (btn?.action === "max") { - directGrid.expandPane(btn.paneIndex) - } else if (btn?.action === "min") { - directGrid.collapsePane() - } else if (btn?.action === "sel") { - directGrid.enterSelectMode() - } else { - directGrid.focusByClick(me.col, me.row) - } - continue - } - } - - // Strip mouse events from the data before keyboard filtering - let stripped = str - // Remove mouse events from end to start to preserve indices - for (let i = mouseEvents.length - 1; i >= 0; i--) { - const me = mouseEvents[i] - stripped = stripped.slice(0, me.start) + stripped.slice(me.end) - } - - const keyboard = extractKeyboardInput(stripped) - if (keyboard) { - const dir = SHIFT_ARROWS[keyboard] - if (dir) { - directGrid.focusByDirection(dir) - } else { - handleGridInput(keyboard) - } - } - return - } - - // Picker mode: handle mouse clicks (scroll + click-to-select) - const pickerMouse = extractMouseEvents(str) - for (const me of pickerMouse) { - if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) - if (me.btn === 64) { if (cursor > 0) { cursor--; updateAll() } } - if (me.btn === 65) { if (cursor < displayRows.length - 1) { cursor++; updateAll() } } - } - - // Parse keys directly (bypass OpenTUI pipeline to avoid double-firing) - const keyboard = extractKeyboardInput(str) - if (!keyboard) return - - // Map raw sequences to KeyEvent-like objects and call handleKeypress directly - const keyMap: Record = { - "\x1b[A": { name: "up" }, - "\x1b[B": { name: "down" }, - "\x1b[C": { name: "right" }, - "\x1b[D": { name: "left" }, - "\x1b[5~": { name: "pageup" }, - "\x1b[6~": { name: "pagedown" }, - "\x1b[H": { name: "home" }, - "\x1b[F": { name: "end" }, - "\x1bOH": { name: "home" }, - "\x1bOF": { name: "end" }, - "\x1b[Z": { name: "tab", shift: true }, - "\x1b[1;2A": { name: "up", shift: true }, - "\x1b[1;2B": { name: "down", shift: true }, - "\x1b[1;2C": { name: "right", shift: true }, - "\x1b[1;2D": { name: "left", shift: true }, - "\x09": { name: "tab" }, - "\x0d": { name: "return" }, - "\x1b": { name: "escape" }, - " ": { name: "space" }, - } - - // Parse sequences from the keyboard string - let ki = 0 - while (ki < keyboard.length) { - let matched = false - // Try longest match first (up to 8 chars for CSI sequences) - for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { - const seq = keyboard.slice(ki, ki + len) - const mapped = keyMap[seq] - if (mapped) { - const syntheticKey = { - name: mapped.name, - shift: mapped.shift || false, - ctrl: mapped.ctrl || false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) - ki += len - matched = true - break - } - } - if (!matched) { - // Single printable character - const ch = keyboard[ki] - const code = ch.charCodeAt(0) - // Map printable ASCII to key name - if (code >= 0x21 && code <= 0x7e) { - const syntheticKey = { - name: ch, - shift: false, - ctrl: false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) - } - ki++ - } - } - } process.stdin.on("data", stdinHandler) // Live session monitoring - if (demoMode) { - generateMockActiveSessions(projects) - generateMockBusySessions(projects) - for (const p of projects) { + if (app.demoMode) { + generateMockActiveSessions(app.projects) + generateMockBusySessions(app.projects) + for (const p of app.projects) { if (p.activeSessions > 0 && !p.sessions) { p.sessions = generateMockSessions(p.path) p.sessionCount = p.sessions.length } populateMockSessionStatus(p) } - prevBusySnapshot = snapshotBusy(projects) + app.prevBusySnapshot = snapshotBusy(app.projects) updateAll() } else { detectActiveSessions().then((sessions) => { - if (updateProjectSessions(projects, sessions)) updateAll() - prevBusySnapshot = snapshotBusy(projects) + if (updateProjectSessions(app.projects, sessions)) updateAll() + app.prevBusySnapshot = snapshotBusy(app.projects) }) } let usageTick = 0 - monitorInterval = setInterval(async () => { - if (destroyed) return + app.monitorInterval = setInterval(async () => { + if (app.destroyed) return - // Refresh usage every ~30s (6 ticks of 5s) usageTick++ if (usageTick % 6 === 0) { try { - cachedUsage = await getUsageSummary() + app.cachedUsage = await getUsageSummary() updateUsagePanel() } catch {} } - if (demoMode) { - for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 } - generateMockActiveSessions(projects) - generateMockBusySessions(projects) - for (const p of projects) { + if (app.demoMode) { + for (const p of app.projects) { p.activeSessions = 0; p.busySessions = 0 } + generateMockActiveSessions(app.projects) + generateMockBusySessions(app.projects) + for (const p of app.projects) { if (p.activeSessions > 0 && !p.sessions) { p.sessions = generateMockSessions(p.path) p.sessionCount = p.sessions.length } populateMockSessionStatus(p) } - const transitioned = checkTransitions(projects, prevBusySnapshot) - prevBusySnapshot = snapshotBusy(projects) + const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) + app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { playDoneSound() bounceDock() - bottomPanelMode = "idle" + app.bottomPanelMode = "idle" } updateAll() } else { const sessions = await detectActiveSessions() - const changed = updateProjectSessions(projects, sessions) - const transitioned = checkTransitions(projects, prevBusySnapshot) - // Eagerly load/refresh session data for active projects (needed for idle panel) - for (const p of projects) { + const changed = updateProjectSessions(app.projects, sessions) + const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) + for (const p of app.projects) { if (p.activeSessions > 0 && (!p.sessions || transitioned.length > 0)) { p.sessions = await loadSessions(p.path) p.sessionCount = p.sessions.length } } - prevBusySnapshot = snapshotBusy(projects) + app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { playDoneSound() bounceDock() - bottomPanelMode = "idle" + app.bottomPanelMode = "idle" } if (changed) updateAll() - // Update grid pane statuses (flash idle sessions) - if (directGrid && viewMode === "grid") { + if (app.directGrid && app.viewMode === "grid") { await refreshAlive() for (const [, s] of getSessions()) { const status = getSessionStatus(s.projectPath, s.sessionId) - if (status === "idle") directGrid.markIdle(s.name) - else if (status === "busy") directGrid.markBusy(s.name) - else directGrid.clearMark(s.name) + if (status === "idle") app.directGrid.markIdle(s.name) + else if (status === "busy") app.directGrid.markBusy(s.name) + else app.directGrid.clearMark(s.name) } } } diff --git a/src/input/handlers.ts b/src/input/handlers.ts new file mode 100644 index 0000000..592ba12 --- /dev/null +++ b/src/input/handlers.ts @@ -0,0 +1,492 @@ +import type { KeyEvent } from "@opentui/core" +import { app } from "../lib/state" +import { updateAll, rebuildDisplayRows, applySortMode } from "../ui/panels" +import { extractKeyboardInput, extractMouseEvents } from "./parser" +import { switchToGrid } from "../grid/view-switch" +import { doLaunch } from "../actions/launch" +import { launchSelections } from "../actions/launcher" +import { loadSessions } from "../data/sessions" +import { loadBranches } from "../data/git" +import { generateMockSessions, generateMockBranches } from "../data/mock" +import { focusTerminalByPath, getSessionStatus, populateMockSessionStatus } from "../data/monitor" +import { stopAllCaptures } from "../pty/capture" + +// Shift+arrow sequences across terminal emulators +const SHIFT_ARROWS: Record = { + "\x1b[1;2A": "up", + "\x1b[1;2B": "down", + "\x1b[1;2C": "right", + "\x1b[1;2D": "left", + "\x1b[a": "up", + "\x1b[b": "down", + "\x1b[c": "right", + "\x1b[d": "left", +} + +export async function expandProject(projectIndex: number) { + const project = app.projects[projectIndex] + if (app.demoMode) { + if (!project.sessions) { + project.sessions = generateMockSessions(project.path) + project.sessionCount = project.sessions.length + } + if (!project.branches) { + project.branches = generateMockBranches(project.path) + } + populateMockSessionStatus(project) + } else { + const loads: Promise[] = [] + 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() +} + +export function hitTestListRow(screenRow: number): number { + const listStartY = 2 + const relY = screenRow - listStartY + app.listBox.scrollTop + if (relY < 0) return -1 + + let y = 0 + for (let i = 0; i < app.displayRows.length; i++) { + const h = app.displayRows[i].type === "session" ? 3 : 1 + if (relY >= y && relY < y + h) return i + y += h + } + return -1 +} + +export function handlePickerClick(_col: number, screenRow: number) { + const idx = hitTestListRow(screenRow) + if (idx < 0 || idx >= app.displayRows.length) return + + app.cursor = idx + const row = app.displayRows[idx] + const project = app.projects[row.projectIndex] + + if (row.type === "project" || row.type === "new-session") { + const path = project.path + if (app.selectedProjects.has(path)) app.selectedProjects.delete(path) + else app.selectedProjects.add(path) + } else if (row.type === "session") { + const session = project.sessions![row.sessionIndex!] + if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id) + else app.selectedSessions.add(session.id) + } else if (row.type === "branch") { + const path = project.path + if (app.selectedBranches.get(path) === row.branchName) { + app.selectedBranches.delete(path) + } else { + app.selectedBranches.set(path, row.branchName!) + } + } + + updateAll() +} + +export async function handleKeypress(key: KeyEvent) { + try { + const total = app.displayRows.length + if (total === 0) return + + switch (key.name) { + case "up": + if (app.cursor > 0) app.cursor-- + break + + case "down": + if (app.cursor < total - 1) app.cursor++ + break + + case "pageup": + app.cursor = Math.max(0, app.cursor - 15) + break + + case "pagedown": + app.cursor = Math.min(total - 1, app.cursor + 15) + break + + case "home": + app.cursor = 0 + break + + case "end": + app.cursor = total - 1 + break + + case "right": { + const row = app.displayRows[app.cursor] + if (row.type === "project") { + const project = app.projects[row.projectIndex] + if (!project.expanded) { + expandProject(row.projectIndex) + return + } + } + return + } + + case "left": { + const row = app.displayRows[app.cursor] + if (row.type === "project") { + app.projects[row.projectIndex].expanded = false + } else { + app.projects[row.projectIndex].expanded = false + const target = row.projectIndex + rebuildDisplayRows() + app.cursor = app.displayRows.findIndex( + (r) => r.type === "project" && r.projectIndex === target + ) + if (app.cursor < 0) app.cursor = 0 + } + rebuildDisplayRows() + if (app.cursor >= app.displayRows.length) app.cursor = app.displayRows.length - 1 + break + } + + case "space": { + const row = app.displayRows[app.cursor] + if (row.type === "project" || row.type === "new-session") { + const path = app.projects[row.projectIndex].path + if (app.selectedProjects.has(path)) app.selectedProjects.delete(path) + else app.selectedProjects.add(path) + } else if (row.type === "session") { + const session = app.projects[row.projectIndex].sessions![row.sessionIndex!] + if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id) + else app.selectedSessions.add(session.id) + } else if (row.type === "branch") { + const path = app.projects[row.projectIndex].path + if (app.selectedBranches.get(path) === row.branchName) { + app.selectedBranches.delete(path) + } else { + app.selectedBranches.set(path, row.branchName!) + } + } + break + } + + case "f": { + const row = app.displayRows[app.cursor] + const project = app.projects[row.projectIndex] + Bun.spawn(["open", project.path]) + break + } + + case "g": { + const row = app.displayRows[app.cursor] + const project = app.projects[row.projectIndex] + if (project.activeSessions > 0) { + const sid = row.type === "session" && project.sessions + ? project.sessions[row.sessionIndex!]?.id + : undefined + await focusTerminalByPath(project.path, sid) + } + return + } + + case "a": + for (const p of app.projects) app.selectedProjects.add(p.path) + break + + case "n": + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + break + + case "i": + app.bottomPanelMode = app.bottomPanelMode === "preview" ? "idle" : "preview" + app.idleCursor = 0 + break + + case "tab": + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { + if (key.shift) { + app.idleCursor = app.idleCursor > 0 ? app.idleCursor - 1 : Math.min(app.cachedIdleSessions.length, 3) - 1 + } else { + app.idleCursor = (app.idleCursor + 1) % Math.min(app.cachedIdleSessions.length, 3) + } + } + break + + case "s": + app.sortMode = (app.sortMode + 1) % app.sortLabels.length + applySortMode() + app.cursor = 0 + break + + case "return": { + const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 + if (hasSelections) { + doLaunch() + break + } + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) { + const focused = await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath) + if (focused) return + } + const returnRow = app.displayRows[app.cursor] + if ( + returnRow.type === "project" && + app.projects[returnRow.projectIndex].activeSessions > 0 + ) { + const focused = await focusTerminalByPath(app.projects[returnRow.projectIndex].path) + if (focused) return + } + doLaunch() + break + } + + case "o": { + const hasOSel = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 + if (!hasOSel) { + const oRow = app.displayRows[app.cursor] + if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path) + } + if (app.selectedProjects.size > 0 || app.selectedSessions.size > 0) { + await launchSelections(app.projects, app.selectedProjects, app.selectedSessions, app.selectedBranches) + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + } + break + } + + case "q": + case "escape": + app.destroyed = true + if (app.monitorInterval) clearInterval(app.monitorInterval) + stopAllCaptures() + process.stdout.write("\x1b[?1006l") + process.stdout.write("\x1b[?1000l") + app.renderer.destroy() + return + + case "t": + if (app.directGrid && app.directGrid.paneCount > 0) { + switchToGrid() + return + } + return + + default: + return + } + + updateAll() + } catch {} +} + +export async function handleGridInput(rawSequence: string): Promise { + if (app.viewMode !== "grid" || !app.directGrid) return false + + if (rawSequence === "\x1b" && app.directGrid.isExpanded) { + app.directGrid.collapsePane() + return true + } + + if (rawSequence === "\x1e" || rawSequence === "\x1b`") { + switchToPicker() + return true + } + + if (rawSequence === "\x0e") { + app.directGrid.focusNext() + return true + } + + if (rawSequence === "\x10") { + app.directGrid.focusPrev() + return true + } + + if (rawSequence === "\x06") { + const pane = app.directGrid.focusedPane + if (pane) Bun.spawn(["open", pane.session.projectPath]) + return true + } + + if (rawSequence === "\x17") { + const pane = app.directGrid.focusedPane + if (pane) { + if (app.directGrid.isExpanded) app.directGrid.collapsePane() + const { killSession } = await import("../pty/session-manager") + app.directGrid.removePane(pane.session.name) + await killSession(pane.session.name) + if (app.directGrid.paneCount === 0) { + switchToPicker() + } + } + return true + } + + if (rawSequence === "\x1b[5~") { + app.directGrid.sendScrollToFocused("up") + return true + } + if (rawSequence === "\x1b[6~") { + app.directGrid.sendScrollToFocused("down") + return true + } + + app.directGrid.sendInputToFocused(rawSequence) + return true +} + +export function switchToPicker() { + app.viewMode = "picker" + if (app.directGrid) { + if (app.directGrid.selectMode) app.directGrid.exitSelectMode() + if (app.directGrid.paneCount > 0) app.directGrid.stop() + } + app.renderer.resume() + process.stdin.removeAllListeners("data") + process.stdin.on("data", stdinHandler) + process.stdout.write("\x1b[?1000h") + process.stdout.write("\x1b[?1006h") + if (app.mainBox) app.mainBox.visible = true + updateAll() + app.renderer.requestRender() +} + +export function stdinHandler(data: string | Buffer) { + const str = typeof data === "string" ? data : data.toString("utf8") + + if (app.viewMode === "grid" && app.directGrid) { + if (app.directGrid.selectMode) { + const keyboard = extractKeyboardInput(str) + if (keyboard === "\x1b") { + app.directGrid.exitSelectMode() + } + return + } + + const mouseEvents = extractMouseEvents(str) + for (const me of mouseEvents) { + if (me.btn === 64) { + app.directGrid.sendScrollToFocused("up", 3) + continue + } + if (me.btn === 65) { + app.directGrid.sendScrollToFocused("down", 3) + continue + } + if (me.btn === 0 && !me.release) { + const btn = app.directGrid.checkButtonClick(me.col, me.row) + if (btn?.action === "max") { + app.directGrid.expandPane(btn.paneIndex) + } else if (btn?.action === "min") { + app.directGrid.collapsePane() + } else if (btn?.action === "sel") { + app.directGrid.enterSelectMode() + } else { + app.directGrid.focusByClick(me.col, me.row) + } + continue + } + } + + let stripped = str + for (let i = mouseEvents.length - 1; i >= 0; i--) { + const me = mouseEvents[i] + stripped = stripped.slice(0, me.start) + stripped.slice(me.end) + } + + const keyboard = extractKeyboardInput(stripped) + if (keyboard) { + const dir = SHIFT_ARROWS[keyboard] + if (dir) { + app.directGrid.focusByDirection(dir) + } else { + handleGridInput(keyboard) + } + } + return + } + + // Picker mode + const pickerMouse = extractMouseEvents(str) + for (const me of pickerMouse) { + if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) + if (me.btn === 64) { if (app.cursor > 0) { app.cursor--; updateAll() } } + if (me.btn === 65) { if (app.cursor < app.displayRows.length - 1) { app.cursor++; updateAll() } } + } + + const keyboard = extractKeyboardInput(str) + if (!keyboard) return + + const keyMap: Record = { + "\x1b[A": { name: "up" }, + "\x1b[B": { name: "down" }, + "\x1b[C": { name: "right" }, + "\x1b[D": { name: "left" }, + "\x1b[5~": { name: "pageup" }, + "\x1b[6~": { name: "pagedown" }, + "\x1b[H": { name: "home" }, + "\x1b[F": { name: "end" }, + "\x1bOH": { name: "home" }, + "\x1bOF": { name: "end" }, + "\x1b[Z": { name: "tab", shift: true }, + "\x1b[1;2A": { name: "up", shift: true }, + "\x1b[1;2B": { name: "down", shift: true }, + "\x1b[1;2C": { name: "right", shift: true }, + "\x1b[1;2D": { name: "left", shift: true }, + "\x09": { name: "tab" }, + "\x0d": { name: "return" }, + "\x1b": { name: "escape" }, + " ": { name: "space" }, + } + + let ki = 0 + while (ki < keyboard.length) { + let matched = false + for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { + const seq = keyboard.slice(ki, ki + len) + const mapped = keyMap[seq] + if (mapped) { + const syntheticKey = { + name: mapped.name, + shift: mapped.shift || false, + ctrl: mapped.ctrl || false, + meta: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as KeyEvent + handleKeypress(syntheticKey) + ki += len + matched = true + break + } + } + if (!matched) { + const ch = keyboard[ki] + const code = ch.charCodeAt(0) + if (code >= 0x21 && code <= 0x7e) { + const syntheticKey = { + name: ch, + shift: false, + ctrl: false, + meta: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as KeyEvent + handleKeypress(syntheticKey) + } + ki++ + } + } +} diff --git a/src/input/parser.ts b/src/input/parser.ts new file mode 100644 index 0000000..c1b0b2a --- /dev/null +++ b/src/input/parser.ts @@ -0,0 +1,102 @@ +// Extract only safe keyboard input from stdin data. +// WHITELIST approach: only recognized keyboard sequences pass through. +// Everything else (mouse events, terminal responses, OSC, DCS, etc.) is dropped. +export function extractKeyboardInput(data: string): string { + let keyboard = "" + let i = 0 + + while (i < data.length) { + const c = data.charCodeAt(i) + + // ESC sequences + if (c === 0x1b) { + if (i + 1 >= data.length) { keyboard += "\x1b"; i++; continue } // lone ESC = Escape key + + const next = data[i + 1] + + // OSC: \x1b] ... (terminated by BEL \x07 or ST \x1b\\) — drop entirely + if (next === "]") { + let j = i + 2 + while (j < data.length) { + if (data[j] === "\x07") { j++; break } + if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } + j++ + } + i = j; continue + } + + // DCS: \x1bP ... ST | APC: \x1b_ ... ST | PM: \x1b^ ... ST — drop entirely + if (next === "P" || next === "_" || next === "^") { + let j = i + 2 + while (j < data.length) { + if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } + j++ + } + i = j; continue + } + + // CSI: \x1b[ + if (next === "[") { + let j = i + 2 + // Consume parameter bytes (0x30-0x3F: digits, ;, <, =, >, ?) + while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3F) j++ + // Consume intermediate bytes (0x20-0x2F) + while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2F) j++ + // Final byte (0x40-0x7E) + if (j < data.length && data.charCodeAt(j) >= 0x40 && data.charCodeAt(j) <= 0x7E) { + const final = data[j] + // Legacy X10 mouse: \x1b[M followed by 3 raw bytes (btn+32, col+32, row+32) + if (final === "M" && j === i + 2) { + i = Math.min(j + 4, data.length); continue + } + // ONLY keep: arrows (A-D), Home (H), End (F), shift-tab (Z), function keys (~) + if ("ABCDHFZ~".includes(final)) { + keyboard += data.slice(i, j + 1) + } + i = j + 1; continue + } + // Incomplete/malformed CSI — drop + i = j; continue + } + + // SS3: \x1bO + letter (F1-F4, keypad) + if (next === "O" && i + 2 < data.length) { + keyboard += data.slice(i, i + 3) + i += 3; continue + } + + // \x1b` (ctrl+backtick) — keep as keyboard shortcut + if (next === "`") { + keyboard += "\x1b`" + i += 2; continue + } + + // Any other \x1b+char — drop (unknown escape sequence) + i += 2; continue + } + + // Regular character: printable ASCII, control chars, UTF-8 — keep + keyboard += data[i] + i++ + } + + return keyboard +} + +// Parse SGR mouse events from raw data. +export function extractMouseEvents(data: string): { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] { + const events: { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] = [] + const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g + let m + while ((m = re.exec(data)) !== null) { + events.push({ + btn: parseInt(m[1]), + col: parseInt(m[2]), + row: parseInt(m[3]), + release: m[4] === "m", + start: m.index, + end: m.index + m[0].length, + }) + } + return events +} diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..66867b9 --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,50 @@ +import type { CliRenderer } from "@opentui/core" +import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core" +import type { Project, DisplayRow } from "./types" +import type { DirectGridRenderer } from "../components/direct-grid" +import type { UsageSummary } from "../data/usage" +import type { IdleSessionInfo } from "../data/monitor" + +export type ViewMode = "picker" | "grid" + +export const app = { + // Config + demoMode: Bun.argv.includes("--demo"), + + // Data + projects: [] as Project[], + selectedProjects: new Set(), + selectedSessions: new Set(), + selectedBranches: new Map(), + cursor: 0, + sortMode: 0, + sortLabels: ["recent", "name", "commit", "sessions"] as const, + sortedIndices: [] as number[], + displayRows: [] as DisplayRow[], + + // Monitor + monitorInterval: null as ReturnType | null, + prevBusySnapshot: new Map(), + bottomPanelMode: "preview" as "preview" | "idle", + destroyed: false, + idleCursor: 0, + cachedIdleSessions: [] as IdleSessionInfo[], + + // Grid mode + viewMode: "picker" as ViewMode, + directGrid: null as DirectGridRenderer | null, + mainBox: null as BoxRenderable | null, + rawStdoutWrite: null as unknown as (s: string) => boolean, + + // UI refs (set during init) + renderer: null as unknown as CliRenderer, + headerText: null as unknown as TextRenderable, + colHeaderText: null as unknown as TextRenderable, + listBox: null as unknown as ScrollBoxRenderable, + bottomRow: null as unknown as BoxRenderable, + previewBox: null as unknown as BoxRenderable, + previewText: null as unknown as TextRenderable, + usageBox: null as unknown as BoxRenderable, + footerText: null as unknown as TextRenderable, + cachedUsage: null as UsageSummary | null, +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..7aeb86a --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,4 @@ +export const CURSOR_BG = "#283457" +export const ACTIVE_BG = "#1a2e1a" +export const ACCENT = "#7aa2f7" +export const DIM_CLR = "#565f89" diff --git a/src/ui/formatters.ts b/src/ui/formatters.ts new file mode 100644 index 0000000..9e8d475 --- /dev/null +++ b/src/ui/formatters.ts @@ -0,0 +1,144 @@ +import { + t, + bold, + dim, + fg, + green, + yellow, + cyan, + magenta, +} from "@opentui/core" +import { app } from "../lib/state" +import { ACCENT } from "../lib/theme" +import { getSessionStatus } from "../data/monitor" +import { timeAgo, formatSize, elapsedCompact } from "../lib/time" + +export 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("") +} + +export function fmtProjectRow(project: import("../lib/types").Project, isSelected: boolean) { + let activeDot: string + let activeTag: string + if (project.activeSessions > 0) { + if (project.busySessions > 0) { + activeDot = green("●") + const count = String(project.activeSessions) + activeTag = project.activeSessions > 1 ? yellow((count + " ").slice(0, 2)) : " " + } else { + activeDot = yellow("◉") + const elapsed = elapsedCompact(project.lastActivityMs) + activeTag = elapsed ? dim((elapsed + " ").slice(0, 2)) : " " + } + } else { + activeDot = dim("○") + activeTag = " " + } + 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` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( + (project.commitAge || "-").padEnd(10) + )}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim( + String(project.sessionCount).padStart(3) + )} ${dim(String(project.totalMessages).padStart(5))} ${dim(project.tags)}` +} + +export function fmtSessionRow( + projectIdx: number, + sessionIdx: number, + isSelected: boolean, + isLastSession: boolean +) { + const project = app.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 status = getSessionStatus(project.path, session.id) + + 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)" + + if (status === "busy") { + return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( + size.padEnd(7) + )} ${fg(ACCENT)('"' + title + '"')} ${green("running")} + ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} + ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` + } + if (status === "idle") { + return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( + size.padEnd(7) + )} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")} + ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} + ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` + } + 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 + '"')}` +} + +export function fmtNewSessionRow(projectIdx: number, isSelected: boolean) { + const check = isSelected ? green("✓") : " " + return t` ${dim("└─")} [${check}] ${green("+ New session")}` +} + +export function fmtBranchRow(projectIdx: number, branchName: string, isSelected: boolean) { + const project = app.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)}` +} diff --git a/src/ui/panels.ts b/src/ui/panels.ts new file mode 100644 index 0000000..dd2049d --- /dev/null +++ b/src/ui/panels.ts @@ -0,0 +1,321 @@ +import { + Box, + Text, + t, + bold, + dim, + fg, + green, + yellow, + cyan, + magenta, +} from "@opentui/core" +import { app } from "../lib/state" +import { CURSOR_BG, ACTIVE_BG, ACCENT, DIM_CLR } from "../lib/theme" +import { getSessionStatus, getIdleSessions } from "../data/monitor" +import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" +import { timeAgo, formatSize, elapsedCompact } from "../lib/time" +import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" + +export function rebuildDisplayRows() { + app.displayRows = [] + for (const idx of app.sortedIndices) { + const project = app.projects[idx] + app.displayRows.push({ type: "project", projectIndex: idx }) + if (project.expanded) { + if (project.branches) { + for (const br of project.branches) { + if (!br.isCurrent) { + app.displayRows.push({ type: "branch", projectIndex: idx, branchName: br.name }) + } + } + } + if (project.sessions) { + for (let si = 0; si < project.sessions.length; si++) { + app.displayRows.push({ type: "session", projectIndex: idx, sessionIndex: si }) + } + } + app.displayRows.push({ type: "new-session", projectIndex: idx }) + } + } +} + +export function applySortMode() { + const indices = Array.from(app.projects.keys()) + switch (app.sortMode) { + case 0: + app.sortedIndices = indices + break + case 1: + app.sortedIndices = indices.sort((a, b) => + app.projects[a].name.localeCompare(app.projects[b].name) + ) + break + case 2: + app.sortedIndices = indices.sort( + (a, b) => (app.projects[b].commitEpoch || 0) - (app.projects[a].commitEpoch || 0) + ) + break + case 3: + app.sortedIndices = indices.sort( + (a, b) => app.projects[b].sessionCount - app.projects[a].sessionCount + ) + break + } + rebuildDisplayRows() +} + +export function updateHeader() { + const total = app.selectedProjects.size + app.selectedSessions.size + const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" + const modeLabel = app.demoMode ? " [DEMO]" : "" + const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) + const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) + const idleCount = activeCount - busyCount + if (activeCount > 0) { + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` + )} │ ${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}` + } else { + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` + )}` + } +} + +export 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` + app.colHeaderText.content = t` ${dim(cols)}` +} + +function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) { + const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6) + const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName + const title = s.sessionTitle.length > 50 ? s.sessionTitle.slice(0, 47) + "..." : s.sessionTitle + const prompt = s.lastPrompt + ? s.lastPrompt.length > 60 ? s.lastPrompt.slice(0, 57) + "..." : s.lastPrompt + : "(no text)" + const response = s.lastResponse + ? s.lastResponse.length > 60 ? s.lastResponse.slice(0, 57) + "..." : s.lastResponse + : "(no response)" + const pointer = isCursor ? "▸" : " " + app.previewBox.add(Text({ content: t` ${yellow("◉")} ${isCursor ? cyan(pointer) : dim(pointer)} ${dim(elapsed)}${bold(name)} ${fg(ACCENT)('"' + title + '"')}`, width: "100%", height: 1 })) + app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + prompt + '"')}`, width: "100%", height: 1 })) + app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 })) +} + +export function updateIdlePanel() { + app.cachedIdleSessions = getIdleSessions(app.projects) + const n = app.cachedIdleSessions.length + app.previewBox.title = ` Idle Sessions (${n}) — enter to focus ` + for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) + if (n === 0) { + app.idleCursor = 0 + app.previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) + return + } + if (app.idleCursor >= n) app.idleCursor = n - 1 + const show = app.cachedIdleSessions.slice(0, 3) + for (let i = 0; i < show.length; i++) { + addIdleRow(show[i], app.idleCursor === i) + } + if (n > 3) { + app.previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 })) + } +} + +export function updateBottomPanel() { + if (app.bottomPanelMode === "idle") { + app.bottomRow.height = 14 + updateIdlePanel() + } else { + for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) + app.previewBox.add(app.previewText) + app.bottomRow.height = 10 + app.previewBox.title = " Preview " + updatePreview() + } +} + +function usageBarColor(p: number) { + return p >= 80 ? yellow : p >= 50 ? cyan : green +} + +export function updateUsagePanel() { + if (app.destroyed) return + for (const child of app.usageBox.getChildren()) app.usageBox.remove(child.id) + + if (!app.cachedUsage) { + app.usageBox.title = " Usage " + app.usageBox.add(Text({ content: t`${dim("Loading...")}`, width: "100%", height: 1 })) + return + } + + const u = app.cachedUsage + const BAR_W = 18 + + const sPct = pct(u.totalCost, PLAN_LIMITS.session) + const sBar = makeBar(u.totalCost, PLAN_LIMITS.session, BAR_W) + const sReset = u.sessionResetMs > 0 ? formatWindow(u.sessionResetMs) : "" + app.usageBox.title = " Usage " + app.usageBox.add(Text({ content: t`${bold("Session")}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${usageBarColor(sPct)(sBar)} ${bold(String(sPct) + "%")}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${dim(sReset ? "resets " + sReset : "")} ${dim(formatCost(u.costPerHour) + "/h")}`, width: "100%", height: 1 })) + + const wPct = pct(u.weekTotal, PLAN_LIMITS.weeklyAll) + const wBar = makeBar(u.weekTotal, PLAN_LIMITS.weeklyAll, BAR_W) + app.usageBox.add(Text({ content: t`${bold("All models")} ${dim(formatCost(u.weekTotal))}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${usageBarColor(wPct)(wBar)} ${bold(String(wPct) + "%")}`, width: "100%", height: 1 })) + + const snPct = pct(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet) + const snBar = makeBar(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet, BAR_W) + app.usageBox.add(Text({ content: t`${bold("Sonnet")} ${dim(formatCost(u.weeklySonnetCost))}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${usageBarColor(snPct)(snBar)} ${bold(String(snPct) + "%")}`, width: "100%", height: 1 })) + + const monthLabel = new Date().toLocaleString("en", { month: "short" }) + app.usageBox.add(Text({ content: t`${bold(monthLabel + " total")} ${dim(formatCost(u.monthlyTotalCost))}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${dim(formatCost(u.costPerHour) + "/h avg · " + u.totalRequests + " reqs")}`, width: "100%", height: 1 })) + + app.renderer.requestRender() +} + +export function updateFooter() { + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { + app.footerText.content = t` ${dim( + "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + )}` + } else { + app.footerText.content = t` ${dim( + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + )}` + } +} + +export function updatePreview() { + if (app.cursor >= app.displayRows.length) { + app.previewText.content = t`${dim(" No selection")}` + return + } + + const row = app.displayRows[app.cursor] + const project = app.projects[row.projectIndex] + + if (row.type === "project") { + app.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!] + const sStatus = getSessionStatus(project.path, s.id) + const sLabel = sStatus === "busy" ? green(" ● running") : sStatus === "idle" ? yellow(" ◉ idle") : "" + app.previewText.content = t` ${bold("Session:")} ${s.title}${sLabel} + ${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 = app.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")}` + app.previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} + ${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg} +${selNote}` + } + } else { + app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)} + ${dim(project.path)}` + } +} + +export function rebuildList() { + for (const child of app.listBox.getChildren()) { + app.listBox.remove(child.id) + } + + for (let i = 0; i < app.displayRows.length; i++) { + const row = app.displayRows[i] + const isCursor = i === app.cursor + const project = app.projects[row.projectIndex] + + let content: ReturnType + let rowHeight = 1 + if (row.type === "project") { + const isSel = app.selectedProjects.has(project.path) + content = fmtProjectRow(project, isSel) + } else if (row.type === "session") { + const session = project.sessions![row.sessionIndex!] + const isSel = app.selectedSessions.has(session.id) + content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false) + rowHeight = 3 + } else if (row.type === "branch") { + const isSel = app.selectedBranches.get(project.path) === row.branchName + content = fmtBranchRow(row.projectIndex, row.branchName!, isSel) + } else { + const isSel = app.selectedProjects.has(project.path) + content = fmtNewSessionRow(row.projectIndex, isSel) + } + + const isActiveProject = row.type === "project" && project.activeSessions > 0 + const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null + const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined + + if (bgColor) { + app.listBox.add( + Box( + { + backgroundColor: bgColor, + shouldFill: true, + width: "100%", + height: rowHeight, + }, + Text({ content }) + ) + ) + } else { + app.listBox.add(Text({ content, width: "100%", height: rowHeight })) + } + } + + ensureCursorVisible() + app.renderer.requestRender() +} + +export function ensureCursorVisible() { + const vpH = app.listBox.viewport.height + if (vpH <= 0) return + + let cursorY = 0 + let cursorH = 1 + for (let i = 0; i < app.displayRows.length; i++) { + const h = app.displayRows[i].type === "session" ? 3 : 1 + if (i === app.cursor) { + cursorH = h + break + } + cursorY += h + } + + const top = app.listBox.scrollTop + if (cursorY < top) { + app.listBox.scrollTo(cursorY) + } else if (cursorY + cursorH > top + vpH) { + app.listBox.scrollTo(cursorY + cursorH - vpH) + } +} + +export function updateAll() { + if (app.destroyed) return + updateHeader() + rebuildList() + updateBottomPanel() + updateFooter() +}