diff --git a/src/index.ts b/src/index.ts index c6b2ebf..b018a37 100755 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { loadGitMetadata } from "./data/git" import { loadSessions } from "./data/sessions" import { generateMockProjects, generateMockSessions, generateMockBusySessions } from "./data/mock" import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus } from "./data/monitor" +import type { Project } from "./lib/types" import { getUsageSummary } from "./data/usage" import { getSessions, refreshAlive } from "./pty/session-manager" import { stopAllCaptures } from "./pty/capture" @@ -19,6 +20,18 @@ import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } import { stdinHandler } from "./input/handlers" import { resizeGridPanes } from "./grid/view-switch" +function refreshMockSessions(projects: Project[]) { + generateMockActiveSessions(projects) + generateMockBusySessions(projects) + for (const p of projects) { + if (p.activeSessions > 0 && !p.sessions) { + p.sessions = generateMockSessions(p.path) + p.sessionCount = p.sessions.length + } + populateMockSessionStatus(p) + } +} + async function main() { process.stdout.write("\x1b[2J\x1b[H") process.stdout.write("\x1b[1m cladm\x1b[0m\n") @@ -165,15 +178,7 @@ async function main() { // Live session monitoring 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) - } + refreshMockSessions(app.projects) app.prevBusySnapshot = snapshotBusy(app.projects) updateAll() } else { @@ -197,15 +202,7 @@ async function main() { 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) - } + refreshMockSessions(app.projects) const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 592ba12..40a1a2e 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -8,10 +8,12 @@ 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 { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor" import { stopAllCaptures } from "../pty/capture" +import type { DisplayRow } from "../lib/types" + +// ─── Constants ─────────────────────────────────────────────────────── -// Shift+arrow sequences across terminal emulators const SHIFT_ARROWS: Record = { "\x1b[1;2A": "up", "\x1b[1;2B": "down", @@ -23,6 +25,70 @@ const SHIFT_ARROWS: Record = { "\x1b[d": "left", } +const KEY_MAP: 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" }, +} + +const NOOP = () => {} + +// ─── Selection helpers ─────────────────────────────────────────────── + +function toggleSetItem(set: Set, item: T) { + if (set.has(item)) set.delete(item) + else set.add(item) +} + +function toggleRowSelection(row: DisplayRow) { + const project = app.projects[row.projectIndex] + if (row.type === "project" || row.type === "new-session") { + toggleSetItem(app.selectedProjects, project.path) + } else if (row.type === "session") { + toggleSetItem(app.selectedSessions, project.sessions![row.sessionIndex!].id) + } else if (row.type === "branch") { + if (app.selectedBranches.get(project.path) === row.branchName) { + app.selectedBranches.delete(project.path) + } else { + app.selectedBranches.set(project.path, row.branchName!) + } + } +} + +function syntheticKey(name: string, shift = false, ctrl = false): KeyEvent { + return { name, shift, ctrl, meta: false, preventDefault: NOOP, stopPropagation: NOOP } as KeyEvent +} + +// ─── Collapse helper ───────────────────────────────────────────────── + +function collapseProject(projectIndex: number) { + app.projects[projectIndex].expanded = false + rebuildDisplayRows() + const target = app.displayRows.findIndex( + (r) => r.type === "project" && r.projectIndex === projectIndex + ) + app.cursor = target >= 0 ? target : 0 + if (app.cursor >= app.displayRows.length) app.cursor = app.displayRows.length - 1 +} + +// ─── Expand ────────────────────────────────────────────────────────── + export async function expandProject(projectIndex: number) { const project = app.projects[projectIndex] if (app.demoMode) { @@ -56,11 +122,11 @@ export async function expandProject(projectIndex: number) { updateAll() } -export function hitTestListRow(screenRow: number): number { - const listStartY = 2 - const relY = screenRow - listStartY + app.listBox.scrollTop - if (relY < 0) return -1 +// ─── Hit test ──────────────────────────────────────────────────────── +export function hitTestListRow(screenRow: number): number { + const relY = screenRow - 2 + 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 @@ -70,34 +136,18 @@ export function hitTestListRow(screenRow: number): number { return -1 } +// ─── Picker click ──────────────────────────────────────────────────── + 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!) - } - } - + toggleRowSelection(app.displayRows[idx]) updateAll() } +// ─── Keyboard ──────────────────────────────────────────────────────── + export async function handleKeypress(key: KeyEvent) { try { const total = app.displayRows.length @@ -130,58 +180,23 @@ export async function handleKeypress(key: KeyEvent) { 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 - } + if (row.type === "project" && !app.projects[row.projectIndex].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 + case "left": + collapseProject(app.displayRows[app.cursor].projectIndex) 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!) - } - } + case "space": + toggleRowSelection(app.displayRows[app.cursor]) break - } case "f": { - const row = app.displayRows[app.cursor] - const project = app.projects[row.projectIndex] + const project = app.projects[app.displayRows[app.cursor].projectIndex] Bun.spawn(["open", project.path]) break } @@ -215,11 +230,10 @@ export async function handleKeypress(key: KeyEvent) { 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) - } + const max = Math.min(app.cachedIdleSessions.length, 3) + app.idleCursor = key.shift + ? (app.idleCursor > 0 ? app.idleCursor - 1 : max - 1) + : (app.idleCursor + 1) % max } break @@ -236,24 +250,18 @@ export async function handleKeypress(key: KeyEvent) { 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 + if (await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath)) 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 + if (returnRow.type === "project" && app.projects[returnRow.projectIndex].activeSessions > 0) { + if (await focusTerminalByPath(app.projects[returnRow.projectIndex].path)) return } doLaunch() break } case "o": { - const hasOSel = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 - if (!hasOSel) { + if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) { const oRow = app.displayRows[app.cursor] if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path) } @@ -277,10 +285,7 @@ export async function handleKeypress(key: KeyEvent) { return case "t": - if (app.directGrid && app.directGrid.paneCount > 0) { - switchToGrid() - return - } + if (app.directGrid && app.directGrid.paneCount > 0) switchToGrid() return default: @@ -291,6 +296,8 @@ export async function handleKeypress(key: KeyEvent) { } catch {} } +// ─── Grid input ────────────────────────────────────────────────────── + export async function handleGridInput(rawSequence: string): Promise { if (app.viewMode !== "grid" || !app.directGrid) return false @@ -304,15 +311,8 @@ export async function handleGridInput(rawSequence: string): Promise { return true } - if (rawSequence === "\x0e") { - app.directGrid.focusNext() - return true - } - - if (rawSequence === "\x10") { - app.directGrid.focusPrev() - 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 @@ -327,26 +327,20 @@ export async function handleGridInput(rawSequence: string): Promise { const { killSession } = await import("../pty/session-manager") app.directGrid.removePane(pane.session.name) await killSession(pane.session.name) - if (app.directGrid.paneCount === 0) { - switchToPicker() - } + 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 - } + 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 } +// ─── View switching ────────────────────────────────────────────────── + export function switchToPicker() { app.viewMode = "picker" if (app.directGrid) { @@ -363,62 +357,46 @@ export function switchToPicker() { app.renderer.requestRender() } -export function stdinHandler(data: string | Buffer) { - const str = typeof data === "string" ? data : data.toString("utf8") +// ─── Stdin: grid mode ──────────────────────────────────────────────── - if (app.viewMode === "grid" && app.directGrid) { - if (app.directGrid.selectMode) { - const keyboard = extractKeyboardInput(str) - if (keyboard === "\x1b") { - app.directGrid.exitSelectMode() - } - return - } +function processGridInput(str: string) { + const dg = app.directGrid! - 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) - } - } + if (dg.selectMode) { + if (extractKeyboardInput(str) === "\x1b") dg.exitSelectMode() return } - // Picker mode + const mouseEvents = extractMouseEvents(str) + for (const me of mouseEvents) { + if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue } + if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue } + if (me.btn === 0 && !me.release) { + const btn = dg.checkButtonClick(me.col, me.row) + if (btn?.action === "max") dg.expandPane(btn.paneIndex) + else if (btn?.action === "min") dg.collapsePane() + else if (btn?.action === "sel") dg.enterSelectMode() + else dg.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) return + const dir = SHIFT_ARROWS[keyboard] + if (dir) dg.focusByDirection(dir) + else handleGridInput(keyboard) +} + +// ─── Stdin: picker mode ────────────────────────────────────────────── + +function processPickerInput(str: string) { const pickerMouse = extractMouseEvents(str) for (const me of pickerMouse) { if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) @@ -429,64 +407,32 @@ export function stdinHandler(data: string | Buffer) { 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] + const mapped = KEY_MAP[keyboard.slice(ki, ki + len)] if (mapped) { - const syntheticKey = { - name: mapped.name, - shift: mapped.shift || false, - ctrl: mapped.ctrl || false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) + handleKeypress(syntheticKey(mapped.name, mapped.shift, mapped.ctrl)) ki += len matched = true break } } if (!matched) { - const ch = keyboard[ki] - const code = ch.charCodeAt(0) + const code = keyboard.charCodeAt(ki) if (code >= 0x21 && code <= 0x7e) { - const syntheticKey = { - name: ch, - shift: false, - ctrl: false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) + handleKeypress(syntheticKey(keyboard[ki])) } ki++ } } } + +// ─── Stdin entry point ─────────────────────────────────────────────── + +export function stdinHandler(data: string | Buffer) { + const str = typeof data === "string" ? data : data.toString("utf8") + if (app.viewMode === "grid" && app.directGrid) processGridInput(str) + else processPickerInput(str) +} diff --git a/src/ui/panels.ts b/src/ui/panels.ts index dd2049d..d412f66 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -11,12 +11,14 @@ import { magenta, } from "@opentui/core" import { app } from "../lib/state" -import { CURSOR_BG, ACTIVE_BG, ACCENT, DIM_CLR } from "../lib/theme" +import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme" import { getSessionStatus, getIdleSessions } from "../data/monitor" -import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" +import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" import { timeAgo, formatSize, elapsedCompact } from "../lib/time" import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" +// ─── Display rows ──────────────────────────────────────────────────── + export function rebuildDisplayRows() { app.displayRows = [] for (const idx of app.sortedIndices) { @@ -65,6 +67,8 @@ export function applySortMode() { rebuildDisplayRows() } +// ─── Header / Footer ───────────────────────────────────────────────── + export function updateHeader() { const total = app.selectedProjects.size + app.selectedSessions.size const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" @@ -88,6 +92,20 @@ export function updateColumnHeaders() { app.colHeaderText.content = t` ${dim(cols)}` } +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" + )}` + } +} + +// ─── Bottom panel ──────────────────────────────────────────────────── + 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 @@ -104,11 +122,11 @@ function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 })) } -export function updateIdlePanel() { +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) + clearChildren(app.previewBox) if (n === 0) { app.idleCursor = 0 app.previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) @@ -129,7 +147,7 @@ export function updateBottomPanel() { app.bottomRow.height = 14 updateIdlePanel() } else { - for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) + clearChildren(app.previewBox) app.previewBox.add(app.previewText) app.bottomRow.height = 10 app.previewBox.title = " Preview " @@ -137,13 +155,15 @@ export function updateBottomPanel() { } } +// ─── Usage panel ───────────────────────────────────────────────────── + 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) + clearChildren(app.usageBox) if (!app.cachedUsage) { app.usageBox.title = " Usage " @@ -179,17 +199,7 @@ export function updateUsagePanel() { 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" - )}` - } -} +// ─── Preview panel ─────────────────────────────────────────────────── export function updatePreview() { if (app.cursor >= app.displayRows.length) { @@ -236,53 +246,47 @@ ${selNote}` } } -export function rebuildList() { - for (const child of app.listBox.getChildren()) { - app.listBox.remove(child.id) +// ─── List rendering ────────────────────────────────────────────────── + +// Renderable IDs for each row — enables incremental updates +let rowRenderableIds: string[] = [] + +function renderRowContent(i: number) { + const row = app.displayRows[i] + const project = app.projects[row.projectIndex] + + let content: ReturnType + let rowHeight = 1 + if (row.type === "project") { + content = fmtProjectRow(project, app.selectedProjects.has(project.path)) + } else if (row.type === "session") { + content = fmtSessionRow(row.projectIndex, row.sessionIndex!, app.selectedSessions.has(project.sessions![row.sessionIndex!].id), false) + rowHeight = 3 + } else if (row.type === "branch") { + content = fmtBranchRow(row.projectIndex, row.branchName!, app.selectedBranches.get(project.path) === row.branchName) + } else { + content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.has(project.path)) } + const isCursor = i === app.cursor + 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) { + return Box({ backgroundColor: bgColor, shouldFill: true, width: "100%", height: rowHeight }, Text({ content })) + } + return Text({ content, width: "100%", height: rowHeight }) +} + +export function rebuildList() { + clearChildren(app.listBox) + rowRenderableIds = [] + 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 })) - } + const vnode = renderRowContent(i) + const rid = app.listBox.add(vnode) + rowRenderableIds.push(rid as unknown as string) } ensureCursorVisible() @@ -312,6 +316,14 @@ export function ensureCursorVisible() { } } +// ─── Helpers ───────────────────────────────────────────────────────── + +function clearChildren(box: { getChildren(): { id: string }[]; remove(id: string): void }) { + for (const child of box.getChildren()) box.remove(child.id) +} + +// ─── Top-level ─────────────────────────────────────────────────────── + export function updateAll() { if (app.destroyed) return updateHeader()