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:
33
src/index.ts
33
src/index.ts
@@ -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) {
|
||||||
|
|||||||
@@ -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]
|
expandProject(row.projectIndex)
|
||||||
if (!project.expanded) {
|
return
|
||||||
expandProject(row.projectIndex)
|
|
||||||
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,62 +357,46 @@ 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") {
|
|
||||||
app.directGrid.exitSelectMode()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const mouseEvents = extractMouseEvents(str)
|
if (dg.selectMode) {
|
||||||
for (const me of mouseEvents) {
|
if (extractKeyboardInput(str) === "\x1b") dg.exitSelectMode()
|
||||||
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
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
134
src/ui/panels.ts
134
src/ui/panels.ts
@@ -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)
|
// 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<typeof t>
|
||||||
|
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++) {
|
for (let i = 0; i < app.displayRows.length; i++) {
|
||||||
const row = app.displayRows[i]
|
const vnode = renderRowContent(i)
|
||||||
const isCursor = i === app.cursor
|
const rid = app.listBox.add(vnode)
|
||||||
const project = app.projects[row.projectIndex]
|
rowRenderableIds.push(rid as unknown as string)
|
||||||
|
|
||||||
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()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user