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