perf: hoist keyMap constant, dedup selection toggles, split stdinHandler

- Move keyMap to module-level KEY_MAP constant (was recreated per keystroke)
- Extract toggleRowSelection() to replace 4x duplicated toggle logic
- Extract refreshMockSessions() to deduplicate demo setup in index.ts
- Split stdinHandler into processGridInput/processPickerInput
- Extract collapseProject helper from nested left/right key handling
- Add shared syntheticKey() and clearChildren() helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 02:14:53 +00:00
parent 6488816d89
commit 61add15531
3 changed files with 238 additions and 283 deletions

View File

@@ -10,6 +10,7 @@ import { loadGitMetadata } from "./data/git"
import { loadSessions } from "./data/sessions" import { loadSessions } from "./data/sessions"
import { generateMockProjects, generateMockSessions, generateMockBusySessions } from "./data/mock" import { generateMockProjects, generateMockSessions, generateMockBusySessions } from "./data/mock"
import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus } from "./data/monitor" 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 { getUsageSummary } from "./data/usage"
import { getSessions, refreshAlive } from "./pty/session-manager" import { getSessions, refreshAlive } from "./pty/session-manager"
import { stopAllCaptures } from "./pty/capture" import { stopAllCaptures } from "./pty/capture"
@@ -19,6 +20,18 @@ import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders }
import { stdinHandler } from "./input/handlers" import { stdinHandler } from "./input/handlers"
import { resizeGridPanes } from "./grid/view-switch" 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() { async function main() {
process.stdout.write("\x1b[2J\x1b[H") process.stdout.write("\x1b[2J\x1b[H")
process.stdout.write("\x1b[1m cladm\x1b[0m\n") process.stdout.write("\x1b[1m cladm\x1b[0m\n")
@@ -165,15 +178,7 @@ async function main() {
// Live session monitoring // Live session monitoring
if (app.demoMode) { if (app.demoMode) {
generateMockActiveSessions(app.projects) refreshMockSessions(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)
}
app.prevBusySnapshot = snapshotBusy(app.projects) app.prevBusySnapshot = snapshotBusy(app.projects)
updateAll() updateAll()
} else { } else {
@@ -197,15 +202,7 @@ async function main() {
if (app.demoMode) { if (app.demoMode) {
for (const p of app.projects) { p.activeSessions = 0; p.busySessions = 0 } for (const p of app.projects) { p.activeSessions = 0; p.busySessions = 0 }
generateMockActiveSessions(app.projects) refreshMockSessions(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(app.projects, app.prevBusySnapshot) const transitioned = checkTransitions(app.projects, app.prevBusySnapshot)
app.prevBusySnapshot = snapshotBusy(app.projects) app.prevBusySnapshot = snapshotBusy(app.projects)
if (transitioned.length > 0) { if (transitioned.length > 0) {

View File

@@ -8,10 +8,12 @@ import { launchSelections } from "../actions/launcher"
import { loadSessions } from "../data/sessions" import { loadSessions } from "../data/sessions"
import { loadBranches } from "../data/git" import { loadBranches } from "../data/git"
import { generateMockSessions, generateMockBranches } from "../data/mock" 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 { stopAllCaptures } from "../pty/capture"
import type { DisplayRow } from "../lib/types"
// ─── Constants ───────────────────────────────────────────────────────
// Shift+arrow sequences across terminal emulators
const SHIFT_ARROWS: Record<string, "up" | "down" | "left" | "right"> = { const SHIFT_ARROWS: Record<string, "up" | "down" | "left" | "right"> = {
"\x1b[1;2A": "up", "\x1b[1;2A": "up",
"\x1b[1;2B": "down", "\x1b[1;2B": "down",
@@ -23,6 +25,70 @@ const SHIFT_ARROWS: Record<string, "up" | "down" | "left" | "right"> = {
"\x1b[d": "left", "\x1b[d": "left",
} }
const KEY_MAP: Record<string, { name: string; shift?: boolean; ctrl?: boolean }> = {
"\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<T>(set: Set<T>, 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) { export async function expandProject(projectIndex: number) {
const project = app.projects[projectIndex] const project = app.projects[projectIndex]
if (app.demoMode) { if (app.demoMode) {
@@ -56,11 +122,11 @@ export async function expandProject(projectIndex: number) {
updateAll() updateAll()
} }
export function hitTestListRow(screenRow: number): number { // ─── Hit test ────────────────────────────────────────────────────────
const listStartY = 2
const relY = screenRow - listStartY + app.listBox.scrollTop
if (relY < 0) return -1
export function hitTestListRow(screenRow: number): number {
const relY = screenRow - 2 + app.listBox.scrollTop
if (relY < 0) return -1
let y = 0 let y = 0
for (let i = 0; i < app.displayRows.length; i++) { for (let i = 0; i < app.displayRows.length; i++) {
const h = app.displayRows[i].type === "session" ? 3 : 1 const h = app.displayRows[i].type === "session" ? 3 : 1
@@ -70,34 +136,18 @@ export function hitTestListRow(screenRow: number): number {
return -1 return -1
} }
// ─── Picker click ────────────────────────────────────────────────────
export function handlePickerClick(_col: number, screenRow: number) { export function handlePickerClick(_col: number, screenRow: number) {
const idx = hitTestListRow(screenRow) const idx = hitTestListRow(screenRow)
if (idx < 0 || idx >= app.displayRows.length) return if (idx < 0 || idx >= app.displayRows.length) return
app.cursor = idx app.cursor = idx
const row = app.displayRows[idx] toggleRowSelection(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() updateAll()
} }
// ─── Keyboard ────────────────────────────────────────────────────────
export async function handleKeypress(key: KeyEvent) { export async function handleKeypress(key: KeyEvent) {
try { try {
const total = app.displayRows.length const total = app.displayRows.length
@@ -130,58 +180,23 @@ export async function handleKeypress(key: KeyEvent) {
case "right": { case "right": {
const row = app.displayRows[app.cursor] const row = app.displayRows[app.cursor]
if (row.type === "project") { if (row.type === "project" && !app.projects[row.projectIndex].expanded) {
const project = app.projects[row.projectIndex]
if (!project.expanded) {
expandProject(row.projectIndex) expandProject(row.projectIndex)
return return
} }
}
return return
} }
case "left": { case "left":
const row = app.displayRows[app.cursor] collapseProject(app.displayRows[app.cursor].projectIndex)
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 break
}
case "space": { case "space":
const row = app.displayRows[app.cursor] toggleRowSelection(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 break
}
case "f": { case "f": {
const row = app.displayRows[app.cursor] const project = app.projects[app.displayRows[app.cursor].projectIndex]
const project = app.projects[row.projectIndex]
Bun.spawn(["open", project.path]) Bun.spawn(["open", project.path])
break break
} }
@@ -215,11 +230,10 @@ export async function handleKeypress(key: KeyEvent) {
case "tab": case "tab":
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) {
if (key.shift) { const max = Math.min(app.cachedIdleSessions.length, 3)
app.idleCursor = app.idleCursor > 0 ? app.idleCursor - 1 : Math.min(app.cachedIdleSessions.length, 3) - 1 app.idleCursor = key.shift
} else { ? (app.idleCursor > 0 ? app.idleCursor - 1 : max - 1)
app.idleCursor = (app.idleCursor + 1) % Math.min(app.cachedIdleSessions.length, 3) : (app.idleCursor + 1) % max
}
} }
break break
@@ -236,24 +250,18 @@ export async function handleKeypress(key: KeyEvent) {
break break
} }
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) { if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) {
const focused = await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath) if (await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath)) return
if (focused) return
} }
const returnRow = app.displayRows[app.cursor] const returnRow = app.displayRows[app.cursor]
if ( if (returnRow.type === "project" && app.projects[returnRow.projectIndex].activeSessions > 0) {
returnRow.type === "project" && if (await focusTerminalByPath(app.projects[returnRow.projectIndex].path)) return
app.projects[returnRow.projectIndex].activeSessions > 0
) {
const focused = await focusTerminalByPath(app.projects[returnRow.projectIndex].path)
if (focused) return
} }
doLaunch() doLaunch()
break break
} }
case "o": { case "o": {
const hasOSel = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) {
if (!hasOSel) {
const oRow = app.displayRows[app.cursor] const oRow = app.displayRows[app.cursor]
if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path) if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path)
} }
@@ -277,10 +285,7 @@ export async function handleKeypress(key: KeyEvent) {
return return
case "t": case "t":
if (app.directGrid && app.directGrid.paneCount > 0) { if (app.directGrid && app.directGrid.paneCount > 0) switchToGrid()
switchToGrid()
return
}
return return
default: default:
@@ -291,6 +296,8 @@ export async function handleKeypress(key: KeyEvent) {
} catch {} } catch {}
} }
// ─── Grid input ──────────────────────────────────────────────────────
export async function handleGridInput(rawSequence: string): Promise<boolean> { export async function handleGridInput(rawSequence: string): Promise<boolean> {
if (app.viewMode !== "grid" || !app.directGrid) return false if (app.viewMode !== "grid" || !app.directGrid) return false
@@ -304,15 +311,8 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
return true return true
} }
if (rawSequence === "\x0e") { if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true }
app.directGrid.focusNext() if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
return true
}
if (rawSequence === "\x10") {
app.directGrid.focusPrev()
return true
}
if (rawSequence === "\x06") { if (rawSequence === "\x06") {
const pane = app.directGrid.focusedPane const pane = app.directGrid.focusedPane
@@ -327,26 +327,20 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
const { killSession } = await import("../pty/session-manager") const { killSession } = await import("../pty/session-manager")
app.directGrid.removePane(pane.session.name) app.directGrid.removePane(pane.session.name)
await killSession(pane.session.name) await killSession(pane.session.name)
if (app.directGrid.paneCount === 0) { if (app.directGrid.paneCount === 0) switchToPicker()
switchToPicker()
}
} }
return true return true
} }
if (rawSequence === "\x1b[5~") { if (rawSequence === "\x1b[5~") { app.directGrid.sendScrollToFocused("up"); return true }
app.directGrid.sendScrollToFocused("up") if (rawSequence === "\x1b[6~") { app.directGrid.sendScrollToFocused("down"); return true }
return true
}
if (rawSequence === "\x1b[6~") {
app.directGrid.sendScrollToFocused("down")
return true
}
app.directGrid.sendInputToFocused(rawSequence) app.directGrid.sendInputToFocused(rawSequence)
return true return true
} }
// ─── View switching ──────────────────────────────────────────────────
export function switchToPicker() { export function switchToPicker() {
app.viewMode = "picker" app.viewMode = "picker"
if (app.directGrid) { if (app.directGrid) {
@@ -363,39 +357,26 @@ export function switchToPicker() {
app.renderer.requestRender() app.renderer.requestRender()
} }
export function stdinHandler(data: string | Buffer) { // ─── Stdin: grid mode ────────────────────────────────────────────────
const str = typeof data === "string" ? data : data.toString("utf8")
if (app.viewMode === "grid" && app.directGrid) { function processGridInput(str: string) {
if (app.directGrid.selectMode) { const dg = app.directGrid!
const keyboard = extractKeyboardInput(str)
if (keyboard === "\x1b") { if (dg.selectMode) {
app.directGrid.exitSelectMode() if (extractKeyboardInput(str) === "\x1b") dg.exitSelectMode()
}
return return
} }
const mouseEvents = extractMouseEvents(str) const mouseEvents = extractMouseEvents(str)
for (const me of mouseEvents) { for (const me of mouseEvents) {
if (me.btn === 64) { if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue }
app.directGrid.sendScrollToFocused("up", 3) if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue }
continue
}
if (me.btn === 65) {
app.directGrid.sendScrollToFocused("down", 3)
continue
}
if (me.btn === 0 && !me.release) { if (me.btn === 0 && !me.release) {
const btn = app.directGrid.checkButtonClick(me.col, me.row) const btn = dg.checkButtonClick(me.col, me.row)
if (btn?.action === "max") { if (btn?.action === "max") dg.expandPane(btn.paneIndex)
app.directGrid.expandPane(btn.paneIndex) else if (btn?.action === "min") dg.collapsePane()
} else if (btn?.action === "min") { else if (btn?.action === "sel") dg.enterSelectMode()
app.directGrid.collapsePane() else dg.focusByClick(me.col, me.row)
} else if (btn?.action === "sel") {
app.directGrid.enterSelectMode()
} else {
app.directGrid.focusByClick(me.col, me.row)
}
continue continue
} }
} }
@@ -407,18 +388,15 @@ export function stdinHandler(data: string | Buffer) {
} }
const keyboard = extractKeyboardInput(stripped) const keyboard = extractKeyboardInput(stripped)
if (keyboard) { if (!keyboard) return
const dir = SHIFT_ARROWS[keyboard] const dir = SHIFT_ARROWS[keyboard]
if (dir) { if (dir) dg.focusByDirection(dir)
app.directGrid.focusByDirection(dir) else handleGridInput(keyboard)
} else { }
handleGridInput(keyboard)
}
}
return
}
// Picker mode // ─── Stdin: picker mode ──────────────────────────────────────────────
function processPickerInput(str: string) {
const pickerMouse = extractMouseEvents(str) const pickerMouse = extractMouseEvents(str)
for (const me of pickerMouse) { for (const me of pickerMouse) {
if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) 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) const keyboard = extractKeyboardInput(str)
if (!keyboard) return if (!keyboard) return
const keyMap: Record<string, { name: string; shift?: boolean; ctrl?: boolean }> = {
"\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 let ki = 0
while (ki < keyboard.length) { while (ki < keyboard.length) {
let matched = false let matched = false
for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) {
const seq = keyboard.slice(ki, ki + len) const mapped = KEY_MAP[keyboard.slice(ki, ki + len)]
const mapped = keyMap[seq]
if (mapped) { if (mapped) {
const syntheticKey = { handleKeypress(syntheticKey(mapped.name, mapped.shift, mapped.ctrl))
name: mapped.name,
shift: mapped.shift || false,
ctrl: mapped.ctrl || false,
meta: false,
preventDefault: () => {},
stopPropagation: () => {},
} as KeyEvent
handleKeypress(syntheticKey)
ki += len ki += len
matched = true matched = true
break break
} }
} }
if (!matched) { if (!matched) {
const ch = keyboard[ki] const code = keyboard.charCodeAt(ki)
const code = ch.charCodeAt(0)
if (code >= 0x21 && code <= 0x7e) { if (code >= 0x21 && code <= 0x7e) {
const syntheticKey = { handleKeypress(syntheticKey(keyboard[ki]))
name: ch,
shift: false,
ctrl: false,
meta: false,
preventDefault: () => {},
stopPropagation: () => {},
} as KeyEvent
handleKeypress(syntheticKey)
} }
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)
}

View File

@@ -11,12 +11,14 @@ import {
magenta, magenta,
} from "@opentui/core" } from "@opentui/core"
import { app } from "../lib/state" 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 { 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 { timeAgo, formatSize, elapsedCompact } from "../lib/time"
import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters"
// ─── Display rows ────────────────────────────────────────────────────
export function rebuildDisplayRows() { export function rebuildDisplayRows() {
app.displayRows = [] app.displayRows = []
for (const idx of app.sortedIndices) { for (const idx of app.sortedIndices) {
@@ -65,6 +67,8 @@ export function applySortMode() {
rebuildDisplayRows() rebuildDisplayRows()
} }
// ─── Header / Footer ─────────────────────────────────────────────────
export function updateHeader() { export function updateHeader() {
const total = app.selectedProjects.size + app.selectedSessions.size const total = app.selectedProjects.size + app.selectedSessions.size
const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : ""
@@ -88,6 +92,20 @@ export function updateColumnHeaders() {
app.colHeaderText.content = t` ${dim(cols)}` 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) { function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) {
const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6) const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6)
const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName 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 })) 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) app.cachedIdleSessions = getIdleSessions(app.projects)
const n = app.cachedIdleSessions.length const n = app.cachedIdleSessions.length
app.previewBox.title = ` Idle Sessions (${n}) — enter to focus ` 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) { if (n === 0) {
app.idleCursor = 0 app.idleCursor = 0
app.previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) 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 app.bottomRow.height = 14
updateIdlePanel() updateIdlePanel()
} else { } else {
for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) clearChildren(app.previewBox)
app.previewBox.add(app.previewText) app.previewBox.add(app.previewText)
app.bottomRow.height = 10 app.bottomRow.height = 10
app.previewBox.title = " Preview " app.previewBox.title = " Preview "
@@ -137,13 +155,15 @@ export function updateBottomPanel() {
} }
} }
// ─── Usage panel ─────────────────────────────────────────────────────
function usageBarColor(p: number) { function usageBarColor(p: number) {
return p >= 80 ? yellow : p >= 50 ? cyan : green return p >= 80 ? yellow : p >= 50 ? cyan : green
} }
export function updateUsagePanel() { export function updateUsagePanel() {
if (app.destroyed) return if (app.destroyed) return
for (const child of app.usageBox.getChildren()) app.usageBox.remove(child.id) clearChildren(app.usageBox)
if (!app.cachedUsage) { if (!app.cachedUsage) {
app.usageBox.title = " Usage " app.usageBox.title = " Usage "
@@ -179,17 +199,7 @@ export function updateUsagePanel() {
app.renderer.requestRender() app.renderer.requestRender()
} }
export function updateFooter() { // ─── Preview panel ───────────────────────────────────────────────────
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() { export function updatePreview() {
if (app.cursor >= app.displayRows.length) { if (app.cursor >= app.displayRows.length) {
@@ -236,53 +246,47 @@ ${selNote}`
} }
} }
export function rebuildList() { // ─── List rendering ──────────────────────────────────────────────────
for (const child of app.listBox.getChildren()) {
app.listBox.remove(child.id)
}
for (let i = 0; i < app.displayRows.length; i++) { // Renderable IDs for each row — enables incremental updates
let rowRenderableIds: string[] = []
function renderRowContent(i: number) {
const row = app.displayRows[i] const row = app.displayRows[i]
const isCursor = i === app.cursor
const project = app.projects[row.projectIndex] const project = app.projects[row.projectIndex]
let content: ReturnType<typeof t> let content: ReturnType<typeof t>
let rowHeight = 1 let rowHeight = 1
if (row.type === "project") { if (row.type === "project") {
const isSel = app.selectedProjects.has(project.path) content = fmtProjectRow(project, app.selectedProjects.has(project.path))
content = fmtProjectRow(project, isSel)
} else if (row.type === "session") { } else if (row.type === "session") {
const session = project.sessions![row.sessionIndex!] content = fmtSessionRow(row.projectIndex, row.sessionIndex!, app.selectedSessions.has(project.sessions![row.sessionIndex!].id), false)
const isSel = app.selectedSessions.has(session.id)
content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false)
rowHeight = 3 rowHeight = 3
} else if (row.type === "branch") { } else if (row.type === "branch") {
const isSel = app.selectedBranches.get(project.path) === row.branchName content = fmtBranchRow(row.projectIndex, row.branchName!, app.selectedBranches.get(project.path) === row.branchName)
content = fmtBranchRow(row.projectIndex, row.branchName!, isSel)
} else { } else {
const isSel = app.selectedProjects.has(project.path) content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.has(project.path))
content = fmtNewSessionRow(row.projectIndex, isSel)
} }
const isCursor = i === app.cursor
const isActiveProject = row.type === "project" && project.activeSessions > 0 const isActiveProject = row.type === "project" && project.activeSessions > 0
const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null
const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined
if (bgColor) { if (bgColor) {
app.listBox.add( return Box({ backgroundColor: bgColor, shouldFill: true, width: "100%", height: rowHeight }, Text({ content }))
Box(
{
backgroundColor: bgColor,
shouldFill: true,
width: "100%",
height: rowHeight,
},
Text({ content })
)
)
} else {
app.listBox.add(Text({ content, width: "100%", height: rowHeight }))
} }
return Text({ content, width: "100%", height: rowHeight })
}
export function rebuildList() {
clearChildren(app.listBox)
rowRenderableIds = []
for (let i = 0; i < app.displayRows.length; i++) {
const vnode = renderRowContent(i)
const rid = app.listBox.add(vnode)
rowRenderableIds.push(rid as unknown as string)
} }
ensureCursorVisible() 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() { export function updateAll() {
if (app.destroyed) return if (app.destroyed) return
updateHeader() updateHeader()