refactor: extract index.ts into 7 focused modules
Break 1507-line index.ts into a slim 251-line entry point plus: - lib/theme.ts, lib/state.ts (shared constants + app singleton) - ui/formatters.ts, ui/panels.ts (row formatting + UI updates) - input/parser.ts, input/handlers.ts (stdin parsing + keyboard/mouse) - grid/view-switch.ts, actions/launch.ts (grid switching + PTY launch) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
71
src/actions/launch.ts
Normal file
71
src/actions/launch.ts
Normal file
@@ -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()
|
||||||
|
}
|
||||||
29
src/grid/view-switch.ts
Normal file
29
src/grid/view-switch.ts
Normal file
@@ -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()
|
||||||
|
}
|
||||||
1404
src/index.ts
1404
src/index.ts
File diff suppressed because it is too large
Load Diff
492
src/input/handlers.ts
Normal file
492
src/input/handlers.ts
Normal file
@@ -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<string, "up" | "down" | "left" | "right"> = {
|
||||||
|
"\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<void>[] = []
|
||||||
|
if (!project.sessions) {
|
||||||
|
loads.push(
|
||||||
|
loadSessions(project.path).then(s => {
|
||||||
|
project.sessions = s
|
||||||
|
project.sessionCount = s.length
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!project.branches) {
|
||||||
|
loads.push(
|
||||||
|
loadBranches(project.path).then(b => { project.branches = b }).catch(() => { project.branches = [] })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (loads.length > 0) await Promise.all(loads)
|
||||||
|
}
|
||||||
|
project.expanded = true
|
||||||
|
rebuildDisplayRows()
|
||||||
|
updateAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<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
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/input/parser.ts
Normal file
102
src/input/parser.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
50
src/lib/state.ts
Normal file
50
src/lib/state.ts
Normal file
@@ -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<string>(),
|
||||||
|
selectedSessions: new Set<string>(),
|
||||||
|
selectedBranches: new Map<string, string>(),
|
||||||
|
cursor: 0,
|
||||||
|
sortMode: 0,
|
||||||
|
sortLabels: ["recent", "name", "commit", "sessions"] as const,
|
||||||
|
sortedIndices: [] as number[],
|
||||||
|
displayRows: [] as DisplayRow[],
|
||||||
|
|
||||||
|
// Monitor
|
||||||
|
monitorInterval: null as ReturnType<typeof setInterval> | null,
|
||||||
|
prevBusySnapshot: new Map<string, number>(),
|
||||||
|
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,
|
||||||
|
}
|
||||||
4
src/lib/theme.ts
Normal file
4
src/lib/theme.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const CURSOR_BG = "#283457"
|
||||||
|
export const ACTIVE_BG = "#1a2e1a"
|
||||||
|
export const ACCENT = "#7aa2f7"
|
||||||
|
export const DIM_CLR = "#565f89"
|
||||||
144
src/ui/formatters.ts
Normal file
144
src/ui/formatters.ts
Normal file
@@ -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)}`
|
||||||
|
}
|
||||||
321
src/ui/panels.ts
Normal file
321
src/ui/panels.ts
Normal file
@@ -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<typeof t>
|
||||||
|
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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user