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:
Alejandro Gutiérrez
2026-02-28 02:01:17 +00:00
parent 3ce6572952
commit 6488816d89
9 changed files with 1287 additions and 1330 deletions

492
src/input/handlers.ts Normal file
View 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
View 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
}