diff --git a/bun.lock b/bun.lock index a8bbe7f..1946f60 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "tui-claude-director", "dependencies": { "@opentui/core": "^0.1.81", + "node-pty": "^1.1.0", }, "devDependencies": { "@types/bun": "latest", @@ -146,6 +147,10 @@ "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], diff --git a/package.json b/package.json index fd05060..f47b484 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "typescript": "^5" }, "dependencies": { - "@opentui/core": "^0.1.81" + "@opentui/core": "^0.1.81", + "node-pty": "^1.1.0" } } diff --git a/src/actions/launch.ts b/src/actions/launch.ts new file mode 100644 index 0000000..c3093f1 --- /dev/null +++ b/src/actions/launch.ts @@ -0,0 +1,165 @@ +import { app } from "../lib/state" +import { updateAll, rebuildDisplayRows } from "../ui/panels" +import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view-switch" +import { loadSessions } from "../data/sessions" +import { createSession } from "../pty/session-manager" + +export async function doAddPane() { + const targetTabId = app.addPaneTargetTabId + if (!targetTabId || !app.directGrid) return + if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return + + type LaunchItem = { path: string; name: string; sessionId?: string; targetBranch?: string } + const items: LaunchItem[] = [] + + 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 + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const totalPanes = items.length + (app.directGrid.getTabPaneCount(targetTabId) || 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, targetTabId) + } + + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + app.addPaneTargetTabId = null + switchToGridTab(targetTabId) +} + +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 + } + + type LaunchItem = { path: string; name: string; tabNum: number; sessionId?: string; targetBranch?: string } + const items: LaunchItem[] = [] + + for (const [path, tabNum] 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, tabNum, 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 + // Sessions without explicit tab number go to tab 1 + items.push({ path: project.path, name: project.name, tabNum: 1, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) + } + } + } + + if (items.length === 0) return + + // Group items by tab number + const byTab = new Map() + for (const item of items) { + if (!byTab.has(item.tabNum)) byTab.set(item.tabNum, []) + byTab.get(item.tabNum)!.push(item) + } + + ensureGridView() + + // Launch each tab group into its own grid tab + for (const [tabNum, tabItems] of byTab) { + // Find existing grid tab for this number, or create one + let targetTabId: number + const existingTab = app.gridTabs.find(t => t.name === `Tab ${tabNum}`) + if (existingTab) { + targetTabId = existingTab.id + } else { + targetTabId = createNewGridTab() + // Rename to match the picker tab number + const tab = app.gridTabs.find(t => t.id === targetTabId) + if (tab) { + tab.name = `Tab ${tabNum}` + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) + } + } + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const totalPanes = tabItems.length + (app.directGrid?.getTabPaneCount(targetTabId) || 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 tabItems) { + 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, targetTabId) + } + + switchToGridTab(targetTabId) + } + + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() +} diff --git a/src/actions/launcher.ts b/src/actions/launcher.ts index 5c66bdd..29fab27 100644 --- a/src/actions/launcher.ts +++ b/src/actions/launcher.ts @@ -1,4 +1,5 @@ import type { Project } from "../lib/types" +import { loadSessions } from "../data/sessions" interface LaunchItem { path: string @@ -8,18 +9,27 @@ interface LaunchItem { export async function launchSelections( projects: Project[], - selectedProjects: Set, + selectedProjects: Map, selectedSessions: Set, selectedBranches: Map = new Map() ): Promise { const byProject = new Map() - for (const path of selectedProjects) { + for (const [path] of selectedProjects) { if (!byProject.has(path)) byProject.set(path, []) const targetBranch = selectedBranches.get(path) const project = projects.find(p => p.path === path) const needsBranch = targetBranch && project && targetBranch !== project.branch - byProject.get(path)!.push({ path, targetBranch: needsBranch ? targetBranch : undefined }) + // Auto-resume most recent session + let lastSessionId: string | undefined + if (project) { + if (!project.sessions) { + project.sessions = await loadSessions(project.path) + project.sessionCount = project.sessions.length + } + lastSessionId = project.sessions[0]?.id + } + byProject.get(path)!.push({ path, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) } for (const project of projects) { @@ -40,7 +50,7 @@ export async function launchSelections( let count = 0 for (const [, items] of byProject) { - const first = items[0] + const first = items[0]! const firstCmd = buildCmd(first) const newWindowScript = [ @@ -55,7 +65,7 @@ export async function launchSelections( for (let i = 1; i < items.length; i++) { await Bun.sleep(400) - const cmd = buildCmd(items[i]) + const cmd = buildCmd(items[i]!) await runOsascript( 'tell application "System Events" to keystroke "t" using command down' diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts new file mode 100644 index 0000000..5968da7 --- /dev/null +++ b/src/components/direct-grid.ts @@ -0,0 +1,1101 @@ +// Direct grid renderer: bypasses OpenTUI entirely for grid mode. +// Draws chrome (borders/titles) and pane content using raw ANSI cursor-addressed writes. +// Each pane renders independently via PTY capture push callbacks. + +import { DirectPane } from "./direct-pane" +import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, getFullBuffer, scrollPane, getScrollOffset } from "../pty/capture" +import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager" +import { app, type GridTab } from "../lib/state" + +export type PaneStatus = "busy" | "idle" | null + +export interface GridPaneInfo { + session: PtySession + directPane: DirectPane + status: PaneStatus + statusSince: number + tabId: number +} + +const PROJECT_COLORS = [ + "#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7", + "#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8", +] + +function getColor(idx: number): string { + return PROJECT_COLORS[idx % PROJECT_COLORS.length]! +} + +function hexFg(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `\x1b[38;2;${r};${g};${b}m` +} + +function hexBg(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `\x1b[48;2;${r};${g};${b}m` +} + +const RESET = "\x1b[0m" +const BOLD = "\x1b[1m" +const DIM = "\x1b[2m" +const WHITE = "\x1b[38;2;255;255;255m" +const HIDE_CURSOR = "\x1b[?25l" +const SHOW_CURSOR = "\x1b[?25h" +const SYNC_START = "\x1b[?2026h" +const SYNC_END = "\x1b[?2026l" +const CLEAR = "\x1b[2J\x1b[H" + +const CYAN_FG = hexFg("#7dcfff") +const YELLOW_FG = hexFg("#e0af68") +const TAB_ACTIVE_BG = hexBg("#1a1b26") +const TAB_DIM_BG = hexBg("#16161e") + +function fmtElapsed(sinceMs: number): string { + if (!sinceMs) return "" + const sec = Math.floor((Date.now() - sinceMs) / 1000) + if (sec < 1) return "0s" + if (sec < 60) return `${sec}s` + const m = Math.floor(sec / 60) + const s = sec % 60 + if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}` + const h = Math.floor(m / 60) + const rm = m % 60 + return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}` +} + +export class DirectGridRenderer { + // Per-tab state + private tabPanes = new Map() + private tabFocus = new Map() + private tabExpanded = new Map() // fullscreen expand + private tabSoftExpand = new Map() // soft expand (70/30) + private _activeTabId = -1 + + private writeRaw: (s: string) => boolean + private flashTimers = new Map>() + private titleTimer: ReturnType | null = null + private running = false + private _selectMode = false + + // Tab bar hit-test regions (col ranges for each tab) + private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] + private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] + private tabBarAddBtnCol = -1 + // Pane name hit-test regions (inline in tab bar, row 1) + private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = [] + + // Pending close state + private _pendingCloseTabId = -1 + private _pendingCloseTimer: ReturnType | null = null + + constructor(rawWrite: (s: string) => boolean) { + this.writeRaw = rawWrite + } + + // ─── Active tab pane accessors ────────────────────────── + + private get panes(): GridPaneInfo[] { + return this.tabPanes.get(this._activeTabId) ?? [] + } + + private get _focusIndex(): number { + return this.tabFocus.get(this._activeTabId) ?? 0 + } + private set _focusIndex(v: number) { + this.tabFocus.set(this._activeTabId, v) + } + + private get _expandedIndex(): number { + return this.tabExpanded.get(this._activeTabId) ?? -1 + } + private set _expandedIndex(v: number) { + this.tabExpanded.set(this._activeTabId, v) + } + + private get _softExpandIndex(): number { + return this.tabSoftExpand.get(this._activeTabId) ?? -1 + } + private set _softExpandIndex(v: number) { + this.tabSoftExpand.set(this._activeTabId, v) + } + + // ─── Lifecycle ───────────────────────────────────────── + + start() { + this.running = true + this.writeRaw(HIDE_CURSOR + CLEAR) + this.drawChrome() + this.titleTimer = setInterval(() => this.refreshTitles(), 1000) + } + + stop() { + this.running = false + if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } + for (const timer of this.flashTimers.values()) clearInterval(timer) + this.flashTimers.clear() + for (const [, panes] of this.tabPanes) { + for (const p of panes) { + p.directPane.detach() + stopCapture(p.session.name) + } + } + this.writeRaw(SHOW_CURSOR) + } + + pause() { + this.running = false + if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } + for (const [, panes] of this.tabPanes) { + for (const p of panes) p.directPane.detach() + } + } + + resume() { + this.running = true + this.writeRaw(HIDE_CURSOR + CLEAR) + const panes = this.panes + for (let i = 0; i < panes.length; i++) { + const p = panes[i]! + const idx = i + p.directPane.attach(p.session.name) + p.directPane.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + } + this.repositionAll() + setTimeout(() => this.forceRedrawAll(), 100) + this.titleTimer = setInterval(() => this.refreshTitles(), 1000) + } + + // ─── Getters ─────────────────────────────────────────── + + get focusIndex() { return this._focusIndex } + get paneCount() { return this.panes.length } + get totalPaneCount() { let n = 0; for (const [, p] of this.tabPanes) n += p.length; return n } + get focusedPane(): GridPaneInfo | null { return this.panes[this._focusIndex] ?? null } + get selectMode() { return this._selectMode } + get isExpanded() { return this._expandedIndex >= 0 } + get isSoftExpanded() { return this._softExpandIndex >= 0 } + get activeTabId() { return this._activeTabId } + + enterSelectMode() { + this._selectMode = true + this.writeRaw("\x1b[?1000l\x1b[?1006l") + this.writeRaw(SHOW_CURSOR) + this.drawSelectView() + } + + exitSelectMode() { + this._selectMode = false + this.writeRaw("\x1b[?1000h\x1b[?1006h") + this.writeRaw(HIDE_CURSOR + CLEAR) + this.forceRedrawAll() + } + + private drawSelectView() { + const pane = this.focusedPane + if (!pane) return + const termW = process.stdout.columns || 120 + const lines = getFullBuffer(pane.session.name) ?? [] + const color = getColor(pane.session.colorIndex) + + // Banner + all buffer lines dumped as plain text (terminal handles native scrollback) + let out = SYNC_START + CLEAR + + // Banner row + const bannerBg = hexBg("#e0af68") + const bannerFg = "\x1b[38;2;0;0;0m" + const bannerText = " SELECTION MODE " + const hint = " Esc to exit " + const pad = Math.max(0, termW - bannerText.length - hint.length) + out += `\x1b[1;1H${bannerBg}${bannerFg}${BOLD}${bannerText}${" ".repeat(pad)}${hint}${RESET}` + + // Project name on row 2 + out += `\x1b[2;1H${hexFg(color)}${BOLD}${pane.session.projectName}${RESET} ${DIM}drag to select │ cmd+c copy │ scroll up for history${RESET}` + + // Dump full buffer starting row 3 — native terminal scrollback handles overflow + for (let r = 0; r < lines.length; r++) { + out += `\x1b[${r + 3};1H${lines[r]}\x1b[0m` + } + out += SYNC_END + this.writeRaw(out) + } + + expandPane(index?: number) { + const idx = index ?? this._focusIndex + if (idx < 0 || idx >= this.panes.length) return + this._expandedIndex = idx + this._softExpandIndex = -1 + this._focusIndex = idx + this.repositionAll() + } + + collapsePane() { + if (this._selectMode) this.exitSelectMode() + this._expandedIndex = -1 + this._softExpandIndex = -1 + this.repositionAll() + } + + // ─── Soft Expand ────────────────────────────────────── + + softExpandPane(index: number) { + if (index < 0 || index >= this.panes.length) return + this._softExpandIndex = index + this._focusIndex = index + this.repositionAll() + } + + softCollapsePane() { + this._softExpandIndex = -1 + this.repositionAll() + } + + toggleSoftExpand(index: number) { + if (this._softExpandIndex === index) this.softCollapsePane() + else this.softExpandPane(index) + } + + // ─── Tab management ─────────────────────────────────── + + addTab(tab: GridTab) { + this.tabPanes.set(tab.id, []) + this.tabFocus.set(tab.id, 0) + this.tabExpanded.set(tab.id, -1) + this.tabSoftExpand.set(tab.id, -1) + } + + removeTab(tabId: number) { + const panes = this.tabPanes.get(tabId) + if (panes) { + for (const p of panes) { + p.directPane.detach() + stopCapture(p.session.name) + killSession(p.session.name) + this.clearFlash(p.session.name) + } + } + this.tabPanes.delete(tabId) + this.tabFocus.delete(tabId) + this.tabExpanded.delete(tabId) + this.tabSoftExpand.delete(tabId) + } + + // ─── Tab close (double-click confirm) ──────────────── + + get pendingCloseTabId() { return this._pendingCloseTabId } + + requestCloseTab(tabId: number): "pending" | "closed" { + if (this._pendingCloseTabId === tabId) { + // Second click — execute close + this.cancelPendingClose() + this.closeTab(tabId) + return "closed" + } + // First click — mark pending + this.cancelPendingClose() + this._pendingCloseTabId = tabId + this._pendingCloseTimer = setTimeout(() => { + this._pendingCloseTabId = -1 + this._pendingCloseTimer = null + this.drawChrome() + }, 2000) + this.drawChrome() + return "pending" + } + + closeTab(tabId: number): number { + const tabIdx = app.gridTabs.findIndex(t => t.id === tabId) + if (tabIdx < 0) return -1 + this.removeTab(tabId) + app.gridTabs.splice(tabIdx, 1) + return tabIdx + } + + cancelPendingClose() { + if (this._pendingCloseTimer) { + clearTimeout(this._pendingCloseTimer) + this._pendingCloseTimer = null + } + if (this._pendingCloseTabId !== -1) { + this._pendingCloseTabId = -1 + this.drawChrome() + } + } + + setActiveTab(tabId: number) { + if (this._activeTabId === tabId) return + // Detach current tab's panes + if (this._activeTabId >= 0) { + for (const p of this.panes) p.directPane.detach() + } + this._activeTabId = tabId + // Reattach new tab's panes + const panes = this.panes + for (let i = 0; i < panes.length; i++) { + const p = panes[i]! + const idx = i + p.directPane.attach(p.session.name) + p.directPane.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + } + if (this.running) { + this.writeRaw(CLEAR) + this.repositionAll() + setTimeout(() => this.forceRedrawAll(), 100) + } + } + + getTabPaneCount(tabId: number): number { + return this.tabPanes.get(tabId)?.length ?? 0 + } + + getTabPanes(tabId: number): readonly GridPaneInfo[] { + return this.tabPanes.get(tabId) ?? [] + } + + hasIdleInTab(tabId: number): boolean { + const panes = this.tabPanes.get(tabId) + if (!panes) return false + return panes.some(p => p.status === "idle") + } + + // Check if a click hit a button on the top border. Returns action + pane index. + // Hit areas are widened beyond the visible dot characters to make clicking easier. + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus" | "closetab" | "closepane", paneIndex: number, tabId?: number } | null { + // Tab bar check (row 1) — includes inline pane names + if (row === 1) { + // Check close buttons first — widened ±1 around the × character + for (const region of this.tabCloseHitRegions) { + if (col >= region.startCol - 1 && col <= region.endCol + 1) { + return { action: "closetab", paneIndex: -1, tabId: region.tabId } + } + } + // Pane names (inline in tabs) — check before tab regions since they're more specific + for (const region of this.paneListHitRegions) { + if (col >= region.startCol - 1 && col <= region.endCol + 1) { + return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId } + } + } + for (const region of this.tabBarHitRegions) { + if (col >= region.startCol && col <= region.endCol) { + return { action: "tab", paneIndex: -1, tabId: region.tabId } + } + } + // [+] button — widened ±1 + if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol - 1 && col <= this.tabBarAddBtnCol + 3) { + return { action: "newtab", paneIndex: -1 } + } + return null + } + + const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) + for (const i of indicesToCheck) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + + // Top border row — framed [●] buttons + if (row === by) { + if (this.isExpanded) { + // Layout: ...─[●] [●] [●]─╮ + // sel: bw-5..[●]..bw-3 + trailing border + if (col >= bx + bw - 5 && col <= bx + bw - 1) return { action: "sel", paneIndex: i } + // min: bw-9..[●]..bw-7 + space + if (col >= bx + bw - 9 && col <= bx + bw - 6) return { action: "min", paneIndex: i } + // close: bw-13..[●]..bw-11 + space + if (col >= bx + bw - 13 && col <= bx + bw - 10) return { action: "closepane", paneIndex: i } + } else { + // Layout: ...─[●] [●]─╮ + // max: bw-5..[●]..bw-3 + trailing border + if (col >= bx + bw - 5 && col <= bx + bw - 1) return { action: "max", paneIndex: i } + // close: bw-9..[●]..bw-7 + space + if (col >= bx + bw - 9 && col <= bx + bw - 6) return { action: "closepane", paneIndex: i } + } + continue + } + + // Title row (by+1) — click to expand/focus + if (row === by + 1 && !this.isExpanded) { + return { action: "max", paneIndex: i } + } + } + return null + } + + // ─── Pane management ─────────────────────────────────── + + async addPane(session: PtySession, tabId?: number): Promise { + const tid = tabId ?? this._activeTabId + let panes = this.tabPanes.get(tid) + if (!panes) { + panes = [] + this.tabPanes.set(tid, []) + this.tabFocus.set(tid, 0) + this.tabExpanded.set(tid, -1) + this.tabSoftExpand.set(tid, -1) + panes = this.tabPanes.get(tid)! + } + + const isActive = tid === this._activeTabId + const regions = isActive ? this.calcPaneRegions(panes.length + 1) : [{ screenX: 2, screenY: 5, contentW: 20, contentH: 6 }] + const idx = panes.length + const region = regions[Math.min(idx, regions.length - 1)]! + + const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH) + const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0, tabId: tid } + panes.push(info) + + resizeSession(session.name, region.contentW, region.contentH) + startCapture(session) + + if (isActive) { + dp.attach(session.name) + dp.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + this.repositionAll() + + // Force-redraw all panes after a short delay to catch initial frames + // that may have arrived before attach or been cleared by repositionAll + setTimeout(() => this.forceRedrawAll(), 200) + } + + return info + } + + removePane(sessionName: string) { + // Search across all tabs + for (const [tabId, panes] of this.tabPanes) { + const idx = panes.findIndex(p => p.session.name === sessionName) + if (idx < 0) continue + + const pane = panes[idx]! + pane.directPane.detach() + stopCapture(pane.session.name) + killSession(pane.session.name) + this.clearFlash(sessionName) + panes.splice(idx, 1) + + if (tabId === this._activeTabId) { + const fi = this.tabFocus.get(tabId) ?? 0 + if (fi >= panes.length) this.tabFocus.set(tabId, Math.max(0, panes.length - 1)) + // Reset expand states if they reference removed pane + const ei = this.tabExpanded.get(tabId) ?? -1 + if (ei >= panes.length || ei === idx) this.tabExpanded.set(tabId, -1) + const si = this.tabSoftExpand.get(tabId) ?? -1 + if (si >= panes.length || si === idx) this.tabSoftExpand.set(tabId, -1) + this.repositionAll() + } + return + } + } + + // ─── Focus ───────────────────────────────────────────── + + setFocus(index: number) { + if (index < 0 || index >= this.panes.length) return + this._focusIndex = index + this.drawChrome() + } + + focusNext() { + if (this.panes.length === 0) return + this.setFocus((this._focusIndex + 1) % this.panes.length) + } + + focusPrev() { + if (this.panes.length === 0) return + this.setFocus((this._focusIndex - 1 + this.panes.length) % this.panes.length) + } + + focusByDirection(dir: "up" | "down" | "left" | "right") { + // Weighted grid keeps same positions — use standard grid nav for both modes + const n = this.panes.length + if (n <= 1) return + const { cols } = this.calcGrid(n) + const rows = Math.ceil(n / cols) + const curCol = this._focusIndex % cols + const curRow = Math.floor(this._focusIndex / cols) + let nc = curCol, nr = curRow + switch (dir) { + case "left": nc = (curCol - 1 + cols) % cols; break + case "right": nc = (curCol + 1) % cols; break + case "up": nr = (curRow - 1 + rows) % rows; break + case "down": nr = (curRow + 1) % rows; break + } + const idx = nr * cols + nc + if (idx >= 0 && idx < n) this.setFocus(idx) + } + + focusByClick(col: number, row: number): boolean { + if (this.isSoftExpanded) { + // Hit-test against actual pane positions in soft expand layout + for (let i = 0; i < this.panes.length; i++) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + if (col >= bx && col < bx + bw && row >= by && row < by + bh) { + this.setFocus(i) + return true + } + } + return false + } + + const n = this.panes.length + if (n === 0) return false + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const chromeTop = 3 + const { cols } = this.calcGrid(n) + const rows = Math.ceil(n / cols) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - chromeTop - 1) / rows) + const gc = Math.floor((col - 1) / cellW) + const gr = Math.floor((row - chromeTop) / cellH) + const idx = gr * cols + gc + if (idx >= 0 && idx < n) { + this.setFocus(idx) + return true + } + return false + } + + // Determine which pane index was clicked (for soft expand) + getPaneIndexAtClick(col: number, row: number): number { + if (this.isSoftExpanded) { + for (let i = 0; i < this.panes.length; i++) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + if (col >= bx && col < bx + bw && row >= by && row < by + bh) return i + } + return -1 + } + const n = this.panes.length + if (n === 0) return -1 + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const chromeTop = 3 + const { cols } = this.calcGrid(n) + const rows = Math.ceil(n / cols) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - chromeTop - 1) / rows) + const gc = Math.floor((col - 1) / cellW) + const gr = Math.floor((row - chromeTop) / cellH) + const idx = gr * cols + gc + return (idx >= 0 && idx < n) ? idx : -1 + } + + // ─── Chrome ──────────────────────────────────────────── + + drawChrome() { + if (!this.running || this._selectMode) return + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + + let out = SYNC_START + + // Tab bar (row 1) — includes inline pane names + out += this.drawTabBar(termW) + + // Header (row 2) + const n = this.panes.length + const fi = this._focusIndex + 1 + let headerLeft: string, headerRight: string + if (this._selectMode) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET}` + headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}` + } else if (this.isExpanded) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET} │ ${fi}/${n}` + headerRight = `${DIM}${hexFg("#f7768e")}[●]${RESET}${DIM} close │ ${hexFg("#e0af68")}[●]${RESET}${DIM} restore │ ${hexFg("#9ece6a")}[●]${RESET}${DIM} select │ ctrl+space picker${RESET}` + } else if (this.isSoftExpanded) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}` + headerRight = `${DIM}click pane to focus │ ${hexFg("#9ece6a")}[●]${RESET}${DIM} fullscreen │ ctrl+s select │ ctrl+e toggle${RESET}` + } else { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` + headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}[●]${RESET}${DIM} close ${hexFg("#9ece6a")}[●]${RESET}${DIM} expand │ ctrl+s select │ ctrl+space picker${RESET}` + } + out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}` + + // Pane borders + titles + if (this.isExpanded) { + out += this.drawPaneBorder(this._expandedIndex) + } else { + for (let i = 0; i < this.panes.length; i++) { + out += this.drawPaneBorder(i) + } + } + + // Footer (last row) + const pane = this.focusedPane + if (this._selectMode) { + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}` + } else if (this.isExpanded && pane) { + const color = getColor(pane.session.colorIndex) + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ ctrl+s select │ Esc or ${hexFg("#e0af68")}[●]${RESET}${DIM} to restore grid${RESET}` + } else if (pane) { + const color = getColor(pane.session.colorIndex) + const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" + const expandNote = app.clickExpand ? `${DIM} │ click-expand: on${RESET}` : "" + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET}${sid} ${DIM}all input goes to focused pane${RESET}${expandNote}` + } else { + out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+space to return to picker.${RESET}` + } + + out += SYNC_END + this.writeRaw(out) + } + + private drawTabBar(termW: number): string { + this.tabBarHitRegions = [] + this.tabCloseHitRegions = [] + this.paneListHitRegions = [] + this.tabBarAddBtnCol = -1 + + const RED_FG = hexFg("#f7768e") + const TAB_BG_ACTIVE = hexBg("#24283b") + const TAB_BORDER = hexFg("#3b4261") + + let out = `\x1b[1;1H\x1b[${termW}X` + let col = 1 + + // Picker tab (id = -1, meaning: switch to picker) + const pickerActive = app.viewMode === "picker" + if (pickerActive) { + out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● Picker${RESET}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` + } else { + out += ` ${DIM}○ Picker${RESET} ` + } + const pickerStart = pickerActive ? col + 1 : col + 1 + this.tabBarHitRegions.push({ tabId: -1, startCol: pickerStart, endCol: pickerStart + 7 }) + col += 10 + + // Grid tabs — inline pane names instead of tab names + for (const tab of app.gridTabs) { + const isActive = this._activeTabId === tab.id && app.viewMode === "grid" + const isPending = this._pendingCloseTabId === tab.id + const tabPanes = this.tabPanes.get(tab.id) ?? [] + + // Build pane name list for this tab + const paneLabels: { name: string, color: string, status: PaneStatus, isFocused: boolean }[] = [] + for (let pi = 0; pi < tabPanes.length; pi++) { + const p = tabPanes[pi]! + const name = p.session.projectName + const short = name.length > 14 ? name.slice(0, 12) + "…" : name + paneLabels.push({ + name: short, + color: getColor(p.session.colorIndex), + status: p.status, + isFocused: isActive && this._focusIndex === pi, + }) + } + + // Close button text + const closeText = isPending ? `${RED_FG}${BOLD}[●]${RESET}` : `${DIM}[×]${RESET}` + const closeVisLen = 3 + + const tabStartCol = col + + if (isActive) { + // Active tab: ╭ ● pane1 · ◉ pane2 × ╮ + out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ` + col += 2 // ╭ + space + + for (let pi = 0; pi < paneLabels.length; pi++) { + const pl = paneLabels[pi]! + let icon: string + if (pl.status === "busy") icon = `${hexFg("#9ece6a")}●${RESET}` + else if (pl.status === "idle") icon = `${hexFg("#e0af68")}◉${RESET}` + else icon = `${DIM}○${RESET}` + + const paneStartCol = col + if (pl.isFocused) { + out += `${TAB_BG_ACTIVE}${icon} ${hexFg(pl.color)}${BOLD}${pl.name}${RESET}` + } else { + out += `${TAB_BG_ACTIVE}${icon} ${DIM}${pl.name}${RESET}` + } + col += 2 + pl.name.length // icon + space + name + this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 }) + + if (pi < paneLabels.length - 1) { + out += `${TAB_BG_ACTIVE}${DIM} · ${RESET}` + col += 3 + } + } + + if (paneLabels.length === 0) { + out += `${TAB_BG_ACTIVE}${DIM}empty${RESET}` + col += 5 + } + + out += `${TAB_BG_ACTIVE} ${closeText}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` + const closeStartCol = col + 1 + col += 1 + closeVisLen + 1 + 1 // space + [×] + space + ╮ + this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) + this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 }) + } else { + // Inactive tab: ○ pane1 · pane2 × │ + const hasIdle = this.hasIdleInTab(tab.id) + out += ` ` + col += 1 + + for (let pi = 0; pi < paneLabels.length; pi++) { + const pl = paneLabels[pi]! + let icon: string + if (pl.status === "idle") icon = `${YELLOW_FG}◉${RESET}` + else if (pl.status === "busy") icon = `${DIM}●${RESET}` + else icon = `${DIM}○${RESET}` + + const paneStartCol = col + out += `${icon} ${DIM}${pl.name}${RESET}` + col += 2 + pl.name.length + this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 }) + + if (pi < paneLabels.length - 1) { + out += `${DIM} · ${RESET}` + col += 3 + } + } + + if (paneLabels.length === 0) { + out += `${DIM}empty${RESET}` + col += 5 + } + + out += ` ${closeText} ${DIM}│${RESET}` + const closeStartCol = col + 1 + col += 1 + closeVisLen + 1 + 1 // space + [×] + space + │ + this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) + this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 }) + } + } + + // [+] button + out += ` ${DIM}[+]${RESET}` + col += 1 + this.tabBarAddBtnCol = col + col += 3 + + return out + } + + + + private drawPaneBorder(index: number): string { + const pane = this.panes[index]! + const dp = pane.directPane + const isFocused = index === this._focusIndex + const isFlashing = this.flashTimers.has(pane.session.name) + const isSoftExp = this._softExpandIndex === index + + const color = getColor(pane.session.colorIndex) + let borderColor: string + if (isFocused) borderColor = WHITE + else if (isSoftExp) borderColor = hexFg("#bb9af7") + else if (isFlashing) borderColor = hexFg("#ff9e64") + else borderColor = hexFg(color) + + const tl = isFocused ? "┏" : "╭" + const tr = isFocused ? "┓" : "╮" + const bl = isFocused ? "┗" : "╰" + const br = isFocused ? "┛" : "╯" + const hz = isFocused ? "━" : "─" + const vt = isFocused ? "┃" : "│" + + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + + let out = "" + + // Top border with traffic-light buttons — framed for visibility + const RED_BTN = `${hexFg("#f7768e")}[●]${RESET}` // close pane + const YELLOW_BTN = `${hexFg("#e0af68")}[●]${RESET}` // minimize / collapse + const GREEN_BTN = `${hexFg("#9ece6a")}[●]${RESET}` // expand / maximize + const DIM_BTN = `${DIM}[●]${RESET}` + + let btnSection: string + let btnVisibleLen: number + if (this.isExpanded) { + // Expanded: show close · minimize · select(green means select mode) + const selBtn = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}[●]${RESET}` : DIM_BTN + btnSection = `${borderColor}${hz}${RESET}${RED_BTN} ${YELLOW_BTN} ${selBtn}${borderColor}` + btnVisibleLen = 1 + 3 + 1 + 3 + 1 + 3 // ─[●] [●] [●] + } else { + // Grid: show close · expand + btnSection = `${borderColor}${hz}${RESET}${RED_BTN} ${GREEN_BTN}${borderColor}` + btnVisibleLen = 1 + 3 + 1 + 3 // ─[●] [●] + } + const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) + out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` + + // Title row + const nameColor = hexFg(color) + const name = pane.session.projectName + const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : "" + const scrollOff = getScrollOffset(pane.session.name) + const scrollTag = scrollOff > 0 ? ` ${hexFg("#ff9e64")}[SCROLL +${scrollOff}]${RESET}` : "" + let titleContent: string + if (pane.status === "idle") { + titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#e0af68")}IDLE${RESET} ${DIM}${elapsed}${RESET}${scrollTag}` + } else if (pane.status === "busy") { + titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#9ece6a")}RUNNING${RESET} ${DIM}${elapsed}${RESET}${scrollTag}` + } else { + const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" + titleContent = ` ${nameColor}${BOLD}${name}${RESET}${sid}${scrollTag}` + } + out += `\x1b[${by + 1};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X${titleContent}` + out += `\x1b[${by + 1};${bx + bw - 1}H${borderColor}${vt}${RESET}` + + // Subtitle row + out += `\x1b[${by + 2};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X ${DIM}${pane.session.projectPath}${RESET}` + out += `\x1b[${by + 2};${bx + bw - 1}H${borderColor}${vt}${RESET}` + + // Side borders for content rows + for (let r = 0; r < dp.height; r++) { + out += `\x1b[${dp.screenY + r};${bx}H${borderColor}${vt}${RESET}` + out += `\x1b[${dp.screenY + r};${bx + bw - 1}H${borderColor}${vt}${RESET}` + } + + // Bottom border + out += `\x1b[${by + bh - 1};${bx}H${borderColor}${bl}${hz.repeat(bw - 2)}${br}${RESET}` + + return out + } + + // ─── Content rendering ───────────────────────────────── + + private drawPane(index: number, lines: string[]) { + if (this._selectMode) { + if (index === this._focusIndex) this.drawSelectView() + return + } + if (this.isExpanded && index !== this._expandedIndex) return + const pane = this.panes[index] + if (!pane) return + const frame = pane.directPane.buildFrame(lines) + this.writeRaw(SYNC_START + frame + SYNC_END) + } + + forceRedrawAll() { + if (!this.running) return + for (let i = 0; i < this.panes.length; i++) { + const pane = this.panes[i]! + resetHash(`dp_${pane.session.name}`) + const frame = getLatestFrame(pane.session.name) + if (frame) this.drawPane(i, frame.lines) + } + this.drawChrome() + } + + // ─── Input ───────────────────────────────────────────── + + sendInputToFocused(rawSequence: string) { + const pane = this.focusedPane + if (!pane) return + const offset = getScrollOffset(pane.session.name) + if (offset > 0) { + scrollPane(pane.session.name, "down", offset) + this.drawChrome() + } + writeToSession(pane.session.name, rawSequence) + } + + sendScrollToFocused(direction: "up" | "down", lines = 5) { + const pane = this.focusedPane + if (!pane) return + scrollPane(pane.session.name, direction, lines) + this.drawChrome() + } + + // ─── Status ──────────────────────────────────────────── + + markIdle(sessionName: string) { + const pane = this.findPaneAcrossTabs(sessionName) + if (!pane) return + if (pane.status !== "idle") { pane.status = "idle"; pane.statusSince = Date.now() } + this.startFlash(sessionName) + this.drawChrome() + } + + markBusy(sessionName: string) { + const pane = this.findPaneAcrossTabs(sessionName) + if (!pane) return + if (pane.status !== "busy") { pane.status = "busy"; pane.statusSince = Date.now() } + this.clearFlash(sessionName) + this.drawChrome() + } + + clearMark(sessionName: string) { + const pane = this.findPaneAcrossTabs(sessionName) + if (!pane) return + pane.status = null; pane.statusSince = 0 + this.clearFlash(sessionName) + this.drawChrome() + } + + private findPaneAcrossTabs(sessionName: string): GridPaneInfo | null { + for (const [, panes] of this.tabPanes) { + const p = panes.find(p => p.session.name === sessionName) + if (p) return p + } + return null + } + + startFlash(sessionName: string) { + if (this.flashTimers.has(sessionName)) return + const timer = setInterval(() => this.drawChrome(), 400) + this.flashTimers.set(sessionName, timer) + } + + clearFlash(sessionName: string) { + const timer = this.flashTimers.get(sessionName) + if (timer) { clearInterval(timer); this.flashTimers.delete(sessionName) } + } + + // ─── Layout ──────────────────────────────────────────── + + private calcGrid(n?: number): { cols: number, rows: number } { + const count = n ?? this.panes.length + const cols = count <= 1 ? 1 : count <= 2 ? 2 : count <= 4 ? 2 : count <= 6 ? 3 : count <= 9 ? 3 : 4 + return { cols, rows: Math.ceil(count / cols) } + } + + private calcPaneRegions(count?: number): { screenX: number, screenY: number, contentW: number, contentH: number }[] { + const n = count ?? this.panes.length + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const chromeTop = 3 // row 1 = tab bar (with inline panes), row 2 = header, content starts row 3 + const { cols, rows } = this.calcGrid(n) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer + + const regions: { screenX: number, screenY: number, contentW: number, contentH: number }[] = [] + for (let i = 0; i < n; i++) { + const gc = i % cols + const gr = Math.floor(i / cols) + const contentW = cellW - 2 + const contentH = cellH - 4 + const screenX = gc * cellW + 2 + const screenY = chromeTop + gr * cellH + 3 + regions.push({ + screenX, + screenY, + contentW: Math.max(contentW, 10), + contentH: Math.max(contentH, 2), + }) + } + return regions + } + + repositionAll() { + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const chromeTop = 3 + + if (this.isExpanded) { + // Fullscreen: expanded pane gets all space + const contentW = termW - 2 + const contentH = termH - chromeTop - 1 - 4 // -1 footer, -4 border chrome + const pane = this.panes[this._expandedIndex]! + pane.directPane.reposition(2, chromeTop + 3, Math.max(contentW, 10), Math.max(contentH, 2)) + resizeSession(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) + resizeCapture(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) + resetHash(`dp_${pane.session.name}`) + } else if (this.isSoftExpanded) { + // Weighted grid: focused pane's col/row get 70%, others split the rest + const sei = this._softExpandIndex + const n = this.panes.length + const { cols, rows } = this.calcGrid(n) + const focusCol = sei % cols + const focusRow = Math.floor(sei / cols) + const availW = termW + const availH = termH - chromeTop - 1 + + // Compute column widths: focused col gets 70%, others split 30% + const colWidths: number[] = [] + const otherCols = cols - 1 + const focusColW = otherCols > 0 ? Math.floor(availW * 0.7) : availW + const otherColW = otherCols > 0 ? Math.floor((availW - focusColW) / otherCols) : 0 + for (let c = 0; c < cols; c++) colWidths.push(c === focusCol ? focusColW : otherColW) + + // Compute row heights: focused row gets 70%, others split 30% + const rowHeights: number[] = [] + const otherRows = rows - 1 + const focusRowH = otherRows > 0 ? Math.floor(availH * 0.7) : availH + const otherRowH = otherRows > 0 ? Math.floor((availH - focusRowH) / otherRows) : 0 + for (let r = 0; r < rows; r++) rowHeights.push(r === focusRow ? focusRowH : otherRowH) + + // Compute column X offsets + const colX: number[] = [0] + for (let c = 1; c < cols; c++) colX.push(colX[c - 1]! + colWidths[c - 1]!) + + // Compute row Y offsets + const rowY: number[] = [0] + for (let r = 1; r < rows; r++) rowY.push(rowY[r - 1]! + rowHeights[r - 1]!) + + for (let i = 0; i < n; i++) { + const gc = i % cols + const gr = Math.floor(i / cols) + const contentW = Math.max(colWidths[gc]! - 2, 10) + const contentH = Math.max(rowHeights[gr]! - 4, 2) + const screenX = colX[gc]! + 2 + const screenY = chromeTop + rowY[gr]! + 3 + const pane = this.panes[i]! + pane.directPane.reposition(screenX, screenY, contentW, contentH) + resizeSession(pane.session.name, contentW, contentH) + resizeCapture(pane.session.name, contentW, contentH) + resetHash(`dp_${pane.session.name}`) + } + } else { + // Equal grid + const regions = this.calcPaneRegions() + for (let i = 0; i < this.panes.length; i++) { + const pane = this.panes[i]! + const region = regions[i]! + pane.directPane.reposition(region.screenX, region.screenY, region.contentW, region.contentH) + resizeSession(pane.session.name, region.contentW, region.contentH) + resizeCapture(pane.session.name, region.contentW, region.contentH) + resetHash(`dp_${pane.session.name}`) + } + } + if (this.running) { + this.writeRaw(CLEAR) + this.drawChrome() + } + } + + private refreshTitles() { + let needsDraw = false + for (const pane of this.panes) { + if (pane.status && pane.statusSince) needsDraw = true + } + if (needsDraw) this.drawChrome() + } + + destroyAll() { + this.stop() + this.tabPanes.clear() + this.tabFocus.clear() + this.tabExpanded.clear() + this.tabSoftExpand.clear() + this._activeTabId = -1 + } +} diff --git a/src/components/direct-pane.ts b/src/components/direct-pane.ts new file mode 100644 index 0000000..bb9fe12 --- /dev/null +++ b/src/components/direct-pane.ts @@ -0,0 +1,74 @@ +// Lightweight terminal pane: writes raw ANSI from PTY capture directly to stdout. +// No parsing, no FrameBuffer, no OpenTUI. Just cursor-addressed raw lines. + +import { onFrame, hasChanged, resetHash, type CaptureResult } from "../pty/capture" + +export class DirectPane { + screenX: number // 1-based screen column of content area + screenY: number // 1-based screen row of content area + width: number // content columns + height: number // content rows + sessionName = "" + + private unsub: (() => void) | null = null + + // Set by DirectGridRenderer to receive frame updates + onFrame: ((lines: string[], pane: DirectPane) => void) | null = null + + constructor(x: number, y: number, w: number, h: number) { + this.screenX = x + this.screenY = y + this.width = w + this.height = h + } + + attach(sessionName: string) { + this.detach() + this.sessionName = sessionName + resetHash(`dp_${sessionName}`) + this.unsub = onFrame(sessionName, (result) => { + if (!hasChanged(result.lines, `dp_${sessionName}`)) return + if (this.onFrame) this.onFrame(result.lines, this) + }) + } + + detach() { + if (this.unsub) { this.unsub(); this.unsub = null } + if (this.sessionName) resetHash(`dp_${this.sessionName}`) + this.sessionName = "" + this.onFrame = null + } + + reposition(x: number, y: number, w: number, h: number) { + this.screenX = x + this.screenY = y + this.width = w + this.height = h + } + + // Build cursor-addressed ANSI output for this pane's content. + // Returns a string ready for stdout.write(). No allocations beyond the string. + buildFrame(lines: string[]): string { + let out = "" + const x = this.screenX + const y = this.screenY + const w = this.width + const h = this.height + + for (let row = 0; row < h; row++) { + // Position cursor at start of this row + out += `\x1b[${y + row};${x}H` + // Erase w characters (clears old content) + out += `\x1b[${w}X` + + if (row < lines.length) { + // Write raw ANSI line from PTY (already correct width) + out += lines[row] + // Reset SGR to prevent color bleed into border + out += "\x1b[0m" + } + } + + return out + } +} diff --git a/src/components/session-grid.ts b/src/components/session-grid.ts index 0304bdd..ea6b253 100644 --- a/src/components/session-grid.ts +++ b/src/components/session-grid.ts @@ -10,13 +10,31 @@ import { } from "@opentui/core" import { TerminalView, getProjectColor } from "./terminal-view" import type { TmuxSession } from "../tmux/session-manager" -import { sendKeys } from "../tmux/input-bridge" +import { sendKeys, sendMouseEvent } from "../tmux/input-bridge" + +export type PaneStatus = "busy" | "idle" | null export interface GridPane { session: TmuxSession termView: TerminalView borderBox: BoxRenderable titleText: TextRenderable + subtitleText: TextRenderable + status: PaneStatus + statusSince: number // Date.now() when status was set +} + +function fmtElapsed(sinceMs: number): string { + if (!sinceMs) return "" + const sec = Math.floor((Date.now() - sinceMs) / 1000) + if (sec < 1) return "0s" + if (sec < 60) return `${sec}s` + const m = Math.floor(sec / 60) + const s = sec % 60 + if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}` + const h = Math.floor(m / 60) + const rm = m % 60 + return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}` } export class SessionGrid { @@ -25,17 +43,19 @@ export class SessionGrid { private panes: GridPane[] = [] private _focusIndex = 0 private flashTimers = new Map>() + private titleTimer: ReturnType | null = null constructor(renderer: CliRenderer, container: BoxRenderable) { this.renderer = renderer this.container = container + this.titleTimer = setInterval(() => this.refreshTitles(), 1000) } get focusIndex() { return this._focusIndex } get paneCount() { return this.panes.length } get focusedPane(): GridPane | null { return this.panes[this._focusIndex] ?? null } - addSession(session: TmuxSession): GridPane { + addSession(session: TmuxSession, subtitle?: ReturnType): GridPane { const color = getProjectColor(session.colorIndex) const colorRGBA = RGBA.fromHex(color) @@ -55,18 +75,27 @@ export class SessionGrid { }) titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}` - // Calculate pane size (leave room for border + title) + const subtitleText = new TextRenderable(this.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + if (subtitle) subtitleText.content = subtitle + else subtitleText.content = t` ${dim("...")}` + + // Calculate pane size (leave room for border + title + subtitle) const dims = this.calcPaneDims() const termView = new TerminalView(this.renderer, { width: Math.max(dims.w - 2, 10), - height: Math.max(dims.h - 3, 4), + height: Math.max(dims.h - 4, 4), }) borderBox.add(titleText) + borderBox.add(subtitleText) borderBox.add(termView) this.container.add(borderBox) - const pane: GridPane = { session, termView, borderBox, titleText } + const pane: GridPane = { session, termView, borderBox, titleText, subtitleText, status: null, statusSince: 0 } this.panes.push(pane) termView.attach(session) @@ -114,10 +143,110 @@ export class SessionGrid { } } - async sendInputToFocused(rawSequence: string) { + flashFocused() { const pane = this.focusedPane if (!pane) return - await sendKeys(pane.session.name, rawSequence) + const flashColor = RGBA.fromHex("#7dcfff") + pane.borderBox.borderColor = flashColor + this.renderer.requestRender() + setTimeout(() => { + // Restore to heavy white (focused state) + pane.borderBox.borderColor = RGBA.fromHex("#ffffff") + this.renderer.requestRender() + }, 150) + } + + focusByDirection(dir: "up" | "down" | "left" | "right") { + const n = this.panes.length + if (n <= 1) return + const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4 + const rows = Math.ceil(n / cols) + const curCol = this._focusIndex % cols + const curRow = Math.floor(this._focusIndex / cols) + + let newCol = curCol + let newRow = curRow + switch (dir) { + case "left": newCol = (curCol - 1 + cols) % cols; break + case "right": newCol = (curCol + 1) % cols; break + case "up": newRow = (curRow - 1 + rows) % rows; break + case "down": newRow = (curRow + 1) % rows; break + } + const idx = newRow * cols + newCol + if (idx >= 0 && idx < n) { + this._focusIndex = idx + this.updateBorders() + } + } + + focusByClick(col: number, row: number): boolean { + const n = this.panes.length + if (n === 0) return false + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4 + const rows = Math.ceil(n / cols) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - 2) / rows) // -2 for header+footer + const gridCol = Math.floor(col / cellW) + const gridRow = Math.floor((row - 1) / cellH) // -1 for header line + const idx = gridRow * cols + gridCol + if (idx >= 0 && idx < n) { + this._focusIndex = idx + this.updateBorders() + return true + } + return false + } + + sendInputToFocused(rawSequence: string) { + const pane = this.focusedPane + if (!pane) return + sendKeys(pane.session.name, rawSequence) + pane.termView.nudge() + } + + // Hit-test: map absolute screen coords to pane index + relative terminal coords + hitTest(absCol: number, absRow: number): { index: number, relX: number, relY: number } | null { + const n = this.panes.length + if (n === 0) return null + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const gridCols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4 + const gridRows = Math.ceil(n / gridCols) + const cellW = Math.floor(termW / gridCols) + const cellH = Math.floor((termH - 2) / gridRows) + + // Convert 1-based screen coords to grid cell + const gc = Math.floor((absCol - 1) / cellW) + const gr = Math.floor((absRow - 2) / cellH) // row 2 is first grid row (row 1 = header) + if (gc < 0 || gc >= gridCols || gr < 0 || gr >= gridRows) return null + + const idx = gr * gridCols + gc + if (idx < 0 || idx >= n) return null + + // Terminal content within cell: after left border(1) + after top border(1)+title(1)+subtitle(1) + const termStartX = gc * cellW + 2 // 1-based + left border + const termStartY = 2 + gr * cellH + 3 // header(1) + top border(1) + title(1) + subtitle(1) + + return { + index: idx, + relX: absCol - termStartX + 1, // 1-based for tmux + relY: absRow - termStartY + 1, + } + } + + // Forward mouse event to the focused tmux pane with correct relative coordinates + sendMouseToFocused(absCol: number, absRow: number, btn: number, release: boolean) { + const pane = this.focusedPane + if (!pane) return + + const hit = this.hitTest(absCol, absRow) + if (!hit || hit.index !== this._focusIndex) return + if (hit.relX < 1 || hit.relY < 1) return + if (hit.relX > pane.session.width || hit.relY > pane.session.height) return + + sendMouseEvent(pane.session.name, hit.relX, hit.relY, btn, release) } // Flash a pane's border to draw attention (e.g., when session goes idle) @@ -159,34 +288,69 @@ export class SessionGrid { markIdle(sessionName: string) { const pane = this.panes.find(p => p.session.name === sessionName) if (!pane) return + if (pane.status !== "idle") { + pane.status = "idle" + pane.statusSince = Date.now() + } this.startFlash(sessionName) - pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#e0af68")("NEEDS INPUT")}` - this.renderer.requestRender() + this.renderPaneTitle(pane) } markBusy(sessionName: string) { const pane = this.panes.find(p => p.session.name === sessionName) if (!pane) return + if (pane.status !== "busy") { + pane.status = "busy" + pane.statusSince = Date.now() + } this.clearFlash(sessionName) - pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#9ece6a")("RUNNING")}` - this.renderer.requestRender() + this.renderPaneTitle(pane) } clearMark(sessionName: string) { const pane = this.panes.find(p => p.session.name === sessionName) if (!pane) return + pane.status = null + pane.statusSince = 0 this.clearFlash(sessionName) + this.renderPaneTitle(pane) + } + + private renderPaneTitle(pane: GridPane) { const color = getProjectColor(pane.session.colorIndex) - pane.titleText.content = t` ${bold(fg(color)(pane.session.projectName))}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}` + const name = bold(fg(color)(pane.session.projectName)) + const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : "" + + if (pane.status === "idle") { + pane.titleText.content = t` ${name} ${fg("#e0af68")("IDLE")} ${dim(elapsed)}` + } else if (pane.status === "busy") { + pane.titleText.content = t` ${name} ${fg("#9ece6a")("RUNNING")} ${dim(elapsed)}` + } else { + pane.titleText.content = t` ${name}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}` + } this.renderer.requestRender() } + private refreshTitles() { + let needsRender = false + for (const pane of this.panes) { + if (pane.status && pane.statusSince) { + this.renderPaneTitle(pane) + needsRender = true + } + } + if (needsRender) this.renderer.requestRender() + } + private updateBorders() { for (let i = 0; i < this.panes.length; i++) { const pane = this.panes[i] const isFocused = i === this._focusIndex const color = getProjectColor(pane.session.colorIndex) + // Update terminal view focus state (controls poll rate) + pane.termView.focused = isFocused + // Focused pane gets brighter border, others get dimmer if (isFocused) { pane.borderBox.borderColor = RGBA.fromHex("#ffffff") @@ -241,6 +405,7 @@ export class SessionGrid { } destroyAll() { + if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } for (const timer of this.flashTimers.values()) clearInterval(timer) this.flashTimers.clear() for (const pane of this.panes) { diff --git a/src/components/terminal-view.ts b/src/components/terminal-view.ts index 901077d..38e2dbf 100644 --- a/src/components/terminal-view.ts +++ b/src/components/terminal-view.ts @@ -6,7 +6,7 @@ import { RGBA, TextAttributes, } from "@opentui/core" -import { capturePane, hasChanged, resetHash } from "../tmux/capture" +import { startCapture, stopCapture, setCaptureRate, onFrame, hasChanged, resetHash, type CaptureResult } from "../tmux/capture" import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser" import type { TmuxSession } from "../tmux/session-manager" @@ -28,34 +28,52 @@ export function getProjectColor(colorIndex: number): string { return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length] } +// Push-based terminal view: no poll timers. +// Subscribes to capture stream and renders only when content changes. export class TerminalView extends FrameBufferRenderable { session: TmuxSession | null = null - private pollTimer: ReturnType | null = null + private unsubCapture: (() => void) | null = null private lastFrame: ParsedFrame | null = null private _focused = false - private _flashUntil = 0 // timestamp until which border flashes + private _flashUntil = 0 private _idleSince = 0 + private _frameDirty = false constructor(ctx: RenderContext, options: FrameBufferOptions) { super(ctx, options) } get focused() { return this._focused } - set focused(v: boolean) { this._focused = v } + set focused(v: boolean) { + if (this._focused === v) return + this._focused = v + // Focused pane captures at ~60fps, unfocused at ~5fps + if (this.session) { + setCaptureRate(this.session.name, v ? 16 : 200) + } + } get idleSince() { return this._idleSince } attach(session: TmuxSession) { this.detach() this.session = session - resetHash() - this.startPolling() + resetHash(session.name) + const captureMs = this._focused ? 16 : 200 + startCapture(session.name, captureMs) + // Subscribe to push notifications — no poll timer needed + this.unsubCapture = onFrame(session.name, (frame) => this.onNewFrame(frame)) } detach() { - this.stopPolling() + if (this.unsubCapture) { this.unsubCapture(); this.unsubCapture = null } + if (this.session) { + stopCapture(this.session.name) + resetHash(this.session.name) + } this.session = null this.lastFrame = null + this._frameDirty = false } flash(durationMs = 2000) { @@ -70,29 +88,19 @@ export class TerminalView extends FrameBufferRenderable { this._idleSince = 0 } - private startPolling() { - if (this.pollTimer) return - this.pollTimer = setInterval(() => this.refresh(), 80) + // Hint that input was sent — request immediate render + nudge() { + this.requestRender() } - private stopPolling() { - if (this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - } - - private async refresh() { + private onNewFrame(result: CaptureResult) { if (!this.session) return - - const result = await capturePane(this.session.name) - if (!result) return - - if (!hasChanged(result.lines)) return + if (!hasChanged(result.lines, this.session.name)) return const frame = parseAnsiFrame(result.lines, result.width, result.height) this.lastFrame = frame - this.renderFrameToBuffer(frame) + this._frameDirty = true + this.requestRender() } private renderFrameToBuffer(frame: ParsedFrame) { @@ -113,17 +121,20 @@ export class TerminalView extends FrameBufferRenderable { } } + // Only write to framebuffer when content actually changed (dirty flag) + // Previously this re-rendered EVERY paint cycle — major CPU waste protected renderSelf(buffer: OptimizedBuffer) { - if (this.lastFrame) { + if (this._frameDirty && this.lastFrame) { this.renderFrameToBuffer(this.lastFrame) + this._frameDirty = false } super.renderSelf(buffer) } protected onResize(width: number, height: number) { super.onResize(width, height) + this._frameDirty = true // Re-render frame to new buffer size if (this.session) { - // Resize tmux pane to match (async, fire-and-forget) import("../tmux/session-manager").then(m => { if (this.session) m.resizePane(this.session.name, width, height) }) diff --git a/src/data/monitor.ts b/src/data/monitor.ts index 3facdfb..a5c8ed0 100644 --- a/src/data/monitor.ts +++ b/src/data/monitor.ts @@ -312,15 +312,40 @@ export function updateProjectSessions(projects: Project[], sessions: Map() // path → timestamp when first went idle +const notifiedIdle = new Set() // paths already notified — prevents re-trigger + export function checkTransitions( projects: Project[], prevBusy: Map ): string[] { + const now = Date.now() const transitioned: string[] = [] for (const project of projects) { const prev = prevBusy.get(project.path) || 0 - if (prev > 0 && project.busySessions === 0 && project.activeSessions > 0) { + const isIdle = project.busySessions === 0 && project.activeSessions > 0 + + if (!isIdle) { + // Not idle — clear notification state so next idle transition can fire + notifiedIdle.delete(project.path) + pendingIdle.delete(project.path) + continue + } + + // Already notified for this idle period — skip + if (notifiedIdle.has(project.path)) continue + + if (prev > 0 && !pendingIdle.has(project.path)) { + // Just transitioned busy→idle — start the delay timer + pendingIdle.set(project.path, now) + } + + if (pendingIdle.has(project.path) && now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) { + // Confirmed idle for 10+ seconds — notify once transitioned.push(project.name) + pendingIdle.delete(project.path) + notifiedIdle.add(project.path) } } return transitioned diff --git a/src/data/session-store.ts b/src/data/session-store.ts new file mode 100644 index 0000000..da35b65 --- /dev/null +++ b/src/data/session-store.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs" +import { join, dirname } from "path" +import { app } from "../lib/state" +import type { SavedSession, SavedTab, SavedPane } from "../lib/types" +import { createSession } from "../pty/session-manager" +import { ensureGridView, switchToGridTab } from "../grid/view-switch" + +const SESSION_PATH = join(process.env.HOME ?? "", ".config", "cladm", "session.json") + +export function extractSessionState(): SavedSession | null { + const dg = app.directGrid + if (!dg || app.gridTabs.length === 0) return null + + const tabs: SavedTab[] = [] + for (const tab of app.gridTabs) { + const paneInfos = dg.getTabPanes(tab.id) + const panes: SavedPane[] = [] + for (const p of paneInfos) { + if (!p.session.alive) continue + panes.push({ + projectPath: p.session.projectPath, + projectName: p.session.projectName, + sessionId: p.session.sessionId, + targetBranch: p.session.targetBranch, + }) + } + if (panes.length > 0) { + tabs.push({ id: tab.id, name: tab.name, panes }) + } + } + + if (tabs.length === 0) return null + + const activeIdx = app.gridTabs.findIndex(t => t.id === dg.activeTabId) + return { + version: 1, + savedAt: Date.now(), + activeTabIndex: Math.max(0, activeIdx), + nextTabId: app.nextTabId, + tabs, + } +} + +export function saveSessionSync(data: SavedSession): void { + const dir = dirname(SESSION_PATH) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(SESSION_PATH, JSON.stringify(data, null, 2)) +} + +export async function loadSavedSession(): Promise { + try { + const file = Bun.file(SESSION_PATH) + if (!await file.exists()) return null + const data = await file.json() as SavedSession + if (data.version !== 1 || !Array.isArray(data.tabs)) return null + return data + } catch { + return null + } +} + +export function deleteSavedSession(): void { + try { unlinkSync(SESSION_PATH) } catch {} +} + +export async function restoreSession(saved: SavedSession, useResume: boolean): Promise { + ensureGridView() + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + + let firstTabId: number | null = null + + for (const savedTab of saved.tabs) { + const tabId = app.nextTabId++ + const tab = { id: tabId, name: savedTab.name } + app.gridTabs.push(tab) + app.directGrid!.addTab(tab) + if (firstTabId === null) firstTabId = tabId + + const validPanes = savedTab.panes.filter(p => existsSync(p.projectPath)) + const n = validPanes.length + const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3 + const rows = Math.ceil(n / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const pane of validPanes) { + const session = await createSession({ + projectPath: pane.projectPath, + projectName: pane.projectName, + sessionId: useResume ? pane.sessionId : undefined, + targetBranch: pane.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid!.addPane(session, tabId) + } + } + + // Sort tabs by name + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) + + // Switch to saved active tab + const targetIdx = Math.min(saved.activeTabIndex, app.gridTabs.length - 1) + if (targetIdx >= 0 && app.gridTabs[targetIdx]) { + switchToGridTab(app.gridTabs[targetIdx].id) + } else if (firstTabId !== null) { + switchToGridTab(firstTabId) + } + + deleteSavedSession() + app.savedSession = null + app.restoreMode = null +} diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts new file mode 100644 index 0000000..2193b92 --- /dev/null +++ b/src/grid/view-switch.ts @@ -0,0 +1,71 @@ +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" + + const isNew = !app.directGrid + if (isNew) { + 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") + + if (isNew || app.directGrid!.totalPaneCount === 0) { + app.directGrid!.start() + } else { + app.directGrid!.resume() + } +} + +export function switchToGridTab(tabId: number) { + const tab = app.gridTabs.find(t => t.id === tabId) + if (!tab) return + + // Track last grid tab for Ctrl+Space toggle + app.lastGridTabIndex = app.gridTabs.indexOf(tab) + + if (app.viewMode !== "grid") { + switchToGrid() + } + + app.activeTabIndex = app.gridTabs.indexOf(tab) + 1 + app.directGrid!.setActiveTab(tabId) +} + +export function createNewGridTab(): number { + const tabId = app.nextTabId++ + const tab = { id: tabId, name: `Tab ${tabId}` } + app.gridTabs.push(tab) + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) + + if (!app.directGrid) { + app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) + } + app.directGrid.addTab(tab) + + // Switch to the new tab + switchToGridTab(tabId) + + return tabId +} + +export function resizeGridPanes() { + if (!app.directGrid || app.directGrid.paneCount === 0) return + app.directGrid.repositionAll() +} diff --git a/src/index.ts b/src/index.ts index 584a872..82c2eae 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,998 +1,133 @@ #!/usr/bin/env bun import { createCliRenderer, - Box, - Text, BoxRenderable, TextRenderable, ScrollBoxRenderable, - t, - bold, - dim, - fg, - green, - yellow, - cyan, - magenta, - type KeyEvent, - type CliRenderer, } from "@opentui/core" import { discoverProjects } from "./data/history" -import { loadGitMetadata, loadBranches } from "./data/git" +import { loadGitMetadata } from "./data/git" import { loadSessions } from "./data/sessions" -import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock" -import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor" -import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage" -import { launchSelections } from "./actions/launcher" -import { createSession, getSessions, refreshAlive, type TmuxSession } from "./tmux/session-manager" -import { SessionGrid } from "./components/session-grid" -import { getProjectColor } from "./components/terminal-view" -import type { Project, DisplayRow } from "./lib/types" -import { timeAgo, formatSize, elapsedCompact } from "./lib/time" +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" +import { DIM_CLR } from "./lib/theme" +import { app } from "./lib/state" +import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels" +import { stdinHandler } from "./input/handlers" +import { resizeGridPanes } from "./grid/view-switch" +import { loadSavedSession, extractSessionState, saveSessionSync } from "./data/session-store" -// ─── Theme ────────────────────────────────────────────────────────── -const CURSOR_BG = "#283457" -const ACTIVE_BG = "#1a2e1a" -const ACCENT = "#7aa2f7" -const DIM_CLR = "#565f89" - -// ─── State ────────────────────────────────────────────────────────── -const demoMode = Bun.argv.includes("--demo") -let projects: Project[] = [] -const selectedProjects = new Set() -const selectedSessions = new Set() -const selectedBranches = new Map() -let cursor = 0 -let sortMode = 0 -const sortLabels = ["recent", "name", "commit", "sessions"] -let sortedIndices: number[] = [] -let displayRows: DisplayRow[] = [] -let monitorInterval: ReturnType | null = null -let prevBusySnapshot: Map = new Map() -let bottomPanelMode: "preview" | "idle" = "preview" -let destroyed = false -let idleCursor = 0 -let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = [] - -// ─── Grid Mode State ─────────────────────────────────────────────── -type ViewMode = "picker" | "grid" -let viewMode: ViewMode = "picker" -let sessionGrid: SessionGrid | null = null -let gridContainer: BoxRenderable | null = null -let gridHeader: TextRenderable | null = null -let gridFooter: TextRenderable | null = null -let mainBox: BoxRenderable | null = null - -// ─── UI Refs ──────────────────────────────────────────────────────── -let renderer: CliRenderer -let headerText: TextRenderable -let colHeaderText: TextRenderable -let listBox: ScrollBoxRenderable -let bottomRow: BoxRenderable -let previewBox: BoxRenderable -let previewText: TextRenderable -let usageBox: BoxRenderable -let footerText: TextRenderable -let cachedUsage: UsageSummary | null = null - -// ─── Display Rows ─────────────────────────────────────────────────── -function rebuildDisplayRows() { - displayRows = [] - for (const idx of sortedIndices) { - const project = projects[idx] - displayRows.push({ type: "project", projectIndex: idx }) - if (project.expanded) { - if (project.branches) { - for (const br of project.branches) { - if (!br.isCurrent) { - displayRows.push({ type: "branch", projectIndex: idx, branchName: br.name }) - } - } - } - if (project.sessions) { - for (let si = 0; si < project.sessions.length; si++) { - displayRows.push({ type: "session", projectIndex: idx, sessionIndex: si }) - } - } - displayRows.push({ type: "new-session", projectIndex: idx }) +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) } } -// ─── Sort ─────────────────────────────────────────────────────────── -function applySortMode() { - const indices = Array.from(projects.keys()) - switch (sortMode) { - case 0: - sortedIndices = indices - break - case 1: - sortedIndices = indices.sort((a, b) => - projects[a].name.localeCompare(projects[b].name) - ) - break - case 2: - sortedIndices = indices.sort( - (a, b) => (projects[b].commitEpoch || 0) - (projects[a].commitEpoch || 0) - ) - break - case 3: - sortedIndices = indices.sort( - (a, b) => projects[b].sessionCount - projects[a].sessionCount - ) - break - } - rebuildDisplayRows() -} - -// ─── Row Formatting ───────────────────────────────────────────────── -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("") -} - -function fmtProjectRow(project: 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)}` -} - -function fmtSessionRow( - projectIdx: number, - sessionIdx: number, - isSelected: boolean, - isLastSession: boolean -) { - const project = 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 + '"')}` -} - -function fmtNewSessionRow(projectIdx: number, isSelected: boolean) { - const check = isSelected ? green("✓") : " " - return t` ${dim("└─")} [${check}] ${green("+ New session")}` -} - -function fmtBranchRow(projectIdx: number, branchName: string, isSelected: boolean) { - const project = 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)}` -} - -// ─── UI Updates ───────────────────────────────────────────────────── -function updateHeader() { - const total = selectedProjects.size + selectedSessions.size - const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : "" - const modeLabel = demoMode ? " [DEMO]" : "" - const activeCount = projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) - const busyCount = projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) - const idleCount = activeCount - busyCount - if (activeCount > 0) { - headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( - `sort: ${sortLabels[sortMode]} │ ${projects.length} projects` - )} │ ${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}` - } else { - headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( - `sort: ${sortLabels[sortMode]} │ ${projects.length} projects` - )}` - } -} - -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` - 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 ? "▸" : " " - previewBox.add(Text({ content: t` ${yellow("◉")} ${isCursor ? cyan(pointer) : dim(pointer)} ${dim(elapsed)}${bold(name)} ${fg(ACCENT)('"' + title + '"')}`, width: "100%", height: 1 })) - previewBox.add(Text({ content: t` ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + prompt + '"')}`, width: "100%", height: 1 })) - previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 })) -} - -function updateIdlePanel() { - cachedIdleSessions = getIdleSessions(projects) - const n = cachedIdleSessions.length - previewBox.title = ` Idle Sessions (${n}) — enter to focus ` - // Clear all children and rebuild - for (const child of previewBox.getChildren()) previewBox.remove(child.id) - if (n === 0) { - idleCursor = 0 - previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) - return - } - if (idleCursor >= n) idleCursor = n - 1 - const show = cachedIdleSessions.slice(0, 3) - for (let i = 0; i < show.length; i++) { - addIdleRow(show[i], idleCursor === i) - } - if (n > 3) { - previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 })) - } -} - -function updateBottomPanel() { - if (bottomPanelMode === "idle") { - bottomRow.height = 14 - updateIdlePanel() - } else { - // Restore previewText as sole child - for (const child of previewBox.getChildren()) previewBox.remove(child.id) - previewBox.add(previewText) - bottomRow.height = 10 - previewBox.title = " Preview " - updatePreview() - } -} - -function usageBarColor(p: number) { - return p >= 80 ? yellow : p >= 50 ? cyan : green -} - -function updateUsagePanel() { - if (destroyed) return - for (const child of usageBox.getChildren()) usageBox.remove(child.id) - - if (!cachedUsage) { - usageBox.title = " Usage " - usageBox.add(Text({ content: t`${dim("Loading...")}`, width: "100%", height: 1 })) - return - } - - const u = cachedUsage - const BAR_W = 18 - - // ── Current session ── - 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) : "" - usageBox.title = " Usage " - usageBox.add(Text({ content: t`${bold("Session")}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(sPct)(sBar)} ${bold(String(sPct) + "%")}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${dim(sReset ? "resets " + sReset : "")} ${dim(formatCost(u.costPerHour) + "/h")}`, width: "100%", height: 1 })) - - // ── Weekly all models ── - const wPct = pct(u.weekTotal, PLAN_LIMITS.weeklyAll) - const wBar = makeBar(u.weekTotal, PLAN_LIMITS.weeklyAll, BAR_W) - usageBox.add(Text({ content: t`${bold("All models")} ${dim(formatCost(u.weekTotal))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(wPct)(wBar)} ${bold(String(wPct) + "%")}`, width: "100%", height: 1 })) - - // ── Weekly sonnet only ── - const snPct = pct(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet) - const snBar = makeBar(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet, BAR_W) - usageBox.add(Text({ content: t`${bold("Sonnet")} ${dim(formatCost(u.weeklySonnetCost))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(snPct)(snBar)} ${bold(String(snPct) + "%")}`, width: "100%", height: 1 })) - - // ── Monthly total ── - const monthLabel = new Date().toLocaleString("en", { month: "short" }) - usageBox.add(Text({ content: t`${bold(monthLabel + " total")} ${dim(formatCost(u.monthlyTotalCost))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${dim(formatCost(u.costPerHour) + "/h avg · " + u.totalRequests + " reqs")}`, width: "100%", height: 1 })) - - renderer.requestRender() -} - -function updateFooter() { - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) { - 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 { - footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter launch │ q quit" - )}` - } -} - -function updatePreview() { - if (cursor >= displayRows.length) { - previewText.content = t`${dim(" No selection")}` - return - } - - const row = displayRows[cursor] - const project = projects[row.projectIndex] - - if (row.type === "project") { - 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") : "" - 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 = 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")}` - previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} - ${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg} -${selNote}` - } - } else { - previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)} - ${dim(project.path)}` - } -} - -function rebuildList() { - for (const child of listBox.getChildren()) { - listBox.remove(child.id) - } - - for (let i = 0; i < displayRows.length; i++) { - const row = displayRows[i] - const isCursor = i === cursor - const project = projects[row.projectIndex] - - let content: ReturnType - let rowHeight = 1 - if (row.type === "project") { - const isSel = selectedProjects.has(project.path) - content = fmtProjectRow(project, isSel) - } else if (row.type === "session") { - const session = project.sessions![row.sessionIndex!] - const isSel = selectedSessions.has(session.id) - content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false) - rowHeight = 3 - } else if (row.type === "branch") { - const isSel = selectedBranches.get(project.path) === row.branchName - content = fmtBranchRow(row.projectIndex, row.branchName!, isSel) - } else { - const isSel = 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) { - listBox.add( - Box( - { - backgroundColor: bgColor, - shouldFill: true, - width: "100%", - height: rowHeight, - }, - Text({ content }) - ) - ) - } else { - listBox.add(Text({ content, width: "100%", height: rowHeight })) - } - } - - ensureCursorVisible() - renderer.requestRender() -} - -function ensureCursorVisible() { - const vpH = listBox.viewport.height - if (vpH <= 0) return - - let cursorY = 0 - let cursorH = 1 - for (let i = 0; i < displayRows.length; i++) { - const h = displayRows[i].type === "session" ? 3 : 1 - if (i === cursor) { - cursorH = h - break - } - cursorY += h - } - - const top = listBox.scrollTop - if (cursorY < top) { - listBox.scrollTo(cursorY) - } else if (cursorY + cursorH > top + vpH) { - listBox.scrollTo(cursorY + cursorH - vpH) - } -} - -function updateAll() { - if (destroyed) return - updateHeader() - rebuildList() - updateBottomPanel() - updateFooter() -} - -// ─── Keyboard ─────────────────────────────────────────────────────── -async function handleKeypress(key: KeyEvent) { - try { - const total = displayRows.length - if (total === 0) return - - switch (key.name) { - case "up": - if (cursor > 0) cursor-- - break - - case "down": - if (cursor < total - 1) cursor++ - break - - case "pageup": - cursor = Math.max(0, cursor - 15) - break - - case "pagedown": - cursor = Math.min(total - 1, cursor + 15) - break - - case "home": - cursor = 0 - break - - case "end": - cursor = total - 1 - break - - case "right": { - const row = displayRows[cursor] - if (row.type === "project") { - const project = projects[row.projectIndex] - if (!project.expanded) { - expandProject(row.projectIndex) - return - } - } - return - } - - case "left": { - const row = displayRows[cursor] - if (row.type === "project") { - projects[row.projectIndex].expanded = false - } else { - projects[row.projectIndex].expanded = false - const target = row.projectIndex - rebuildDisplayRows() - cursor = displayRows.findIndex( - (r) => r.type === "project" && r.projectIndex === target - ) - if (cursor < 0) cursor = 0 - } - rebuildDisplayRows() - if (cursor >= displayRows.length) cursor = displayRows.length - 1 - break - } - - case "space": { - const row = displayRows[cursor] - if (row.type === "project" || row.type === "new-session") { - const path = projects[row.projectIndex].path - if (selectedProjects.has(path)) selectedProjects.delete(path) - else selectedProjects.add(path) - } else if (row.type === "session") { - const session = projects[row.projectIndex].sessions![row.sessionIndex!] - if (selectedSessions.has(session.id)) selectedSessions.delete(session.id) - else selectedSessions.add(session.id) - } else if (row.type === "branch") { - const path = projects[row.projectIndex].path - if (selectedBranches.get(path) === row.branchName) { - selectedBranches.delete(path) - } else { - selectedBranches.set(path, row.branchName!) - } - } - if (cursor < total - 1) cursor++ - break - } - - case "f": { - const row = displayRows[cursor] - const project = projects[row.projectIndex] - Bun.spawn(["open", project.path]) - break - } - - case "g": { - const row = displayRows[cursor] - const project = 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 projects) selectedProjects.add(p.path) - break - - case "n": - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - break - - case "i": - bottomPanelMode = bottomPanelMode === "preview" ? "idle" : "preview" - idleCursor = 0 - break - - case "tab": - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) { - if (key.shift) { - idleCursor = idleCursor > 0 ? idleCursor - 1 : Math.min(cachedIdleSessions.length, 3) - 1 - } else { - idleCursor = (idleCursor + 1) % Math.min(cachedIdleSessions.length, 3) - } - } - break - - case "s": - sortMode = (sortMode + 1) % sortLabels.length - applySortMode() - cursor = 0 - break - - case "return": { - const hasSelections = selectedProjects.size > 0 || selectedSessions.size > 0 - // If user has checkmarked items, always launch them - if (hasSelections) { - doLaunch() - break - } - // Focus idle session from idle panel (only when nothing selected) - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0 && idleCursor < cachedIdleSessions.length) { - const focused = await focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath) - if (focused) return - } - // If cursor is on a project row with active session, focus it - const returnRow = displayRows[cursor] - if ( - returnRow.type === "project" && - projects[returnRow.projectIndex].activeSessions > 0 - ) { - const focused = await focusTerminalByPath(projects[returnRow.projectIndex].path) - if (focused) return - } - doLaunch() - break - } - - case "q": - case "escape": - destroyed = true - if (monitorInterval) clearInterval(monitorInterval) - renderer.destroy() - return - - case "t": - // Switch to grid view if there are tmux sessions - if (sessionGrid && sessionGrid.paneCount > 0) { - switchToGrid() - return - } - return - - default: - return - } - - updateAll() - } catch {} -} - -async function expandProject(projectIndex: number) { - const project = projects[projectIndex] - if (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[] = [] - 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() -} - -async function doLaunch() { - if (selectedProjects.size === 0 && selectedSessions.size === 0) return - if (demoMode) { - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - rebuildDisplayRows() - updateAll() - return - } - - // Build launch items - const items: { path: string; name: string; sessionId?: string; targetBranch?: string }[] = [] - - for (const path of selectedProjects) { - const project = projects.find(p => p.path === path) - if (!project) continue - const targetBranch = selectedBranches.get(path) - const needsBranch = targetBranch && targetBranch !== project.branch - items.push({ path, name: project.name, targetBranch: needsBranch ? targetBranch : undefined }) - } - - for (const project of projects) { - if (!project.sessions) continue - for (const session of project.sessions) { - if (selectedSessions.has(session.id)) { - const targetBranch = 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 - - // Create tmux sessions and switch to grid view - ensureGridView() - - const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 - const n = items.length + (sessionGrid?.paneCount || 0) - const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3 - const rows = Math.ceil(n / cols) - const paneW = Math.floor(termW / cols) - 2 - const paneH = Math.floor((termH - 4) / rows) - 3 - - for (const item of items) { - const session = await createSession({ - projectPath: item.path, - projectName: item.name, - sessionId: item.sessionId, - targetBranch: item.targetBranch, - width: Math.max(paneW, 20), - height: Math.max(paneH, 6), - }) - sessionGrid!.addSession(session) - } - - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - updateGridHeader() - updateGridFooter() - renderer.requestRender() -} - -// ─── Grid View ───────────────────────────────────────────────────── -function ensureGridView() { - if (viewMode === "grid" && sessionGrid) return - switchToGrid() -} - -function switchToGrid() { - viewMode = "grid" - if (mainBox) mainBox.visible = false - - if (!gridContainer) { - gridHeader = new TextRenderable(renderer, { - width: "100%", - height: 1, - flexShrink: 0, - }) - - gridContainer = new BoxRenderable(renderer, { - flexDirection: "row", - flexWrap: "wrap", - flexGrow: 1, - width: "100%", - overflow: "hidden", - }) - - gridFooter = new TextRenderable(renderer, { - width: "100%", - height: 1, - flexShrink: 0, - }) - - const gridRoot = new BoxRenderable(renderer, { - flexDirection: "column", - width: "100%", - height: "100%", - }) - gridRoot.add(gridHeader!) - gridRoot.add(gridContainer!) - gridRoot.add(gridFooter!) - renderer.root.add(gridRoot) - - sessionGrid = new SessionGrid(renderer, gridContainer) - } - - if (gridContainer) gridContainer.visible = true - if (gridHeader) gridHeader.visible = true - if (gridFooter) gridFooter.visible = true - updateGridHeader() - updateGridFooter() - renderer.requestRender() -} - -function switchToPicker() { - viewMode = "picker" - if (mainBox) mainBox.visible = true - if (gridContainer) gridContainer.visible = false - if (gridHeader) gridHeader.visible = false - if (gridFooter) gridFooter.visible = false - updateAll() - renderer.requestRender() -} - -function updateGridHeader() { - if (!gridHeader) return - const n = sessionGrid?.paneCount || 0 - const fi = (sessionGrid?.focusIndex ?? 0) + 1 - gridHeader.content = t` ${bold("cladm grid")} — ${String(n)} sessions │ focus: ${String(fi)}/${String(n)} ${dim("ctrl+` picker │ ctrl+n/p switch │ ctrl+w close")}` -} - -function updateGridFooter() { - if (!gridFooter || !sessionGrid) return - const pane = sessionGrid.focusedPane - if (pane) { - const color = getProjectColor(pane.session.colorIndex) - gridFooter.content = t` ${fg(color)("▸")} ${bold(pane.session.projectName)}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""} ${dim("all input goes to focused pane")}` - } else { - gridFooter.content = t` ${dim("No sessions. Press ctrl+\` to return to picker.")}` - } -} - -async function handleGridInput(rawSequence: string): Promise { - if (viewMode !== "grid") return false - - // Ctrl+` (0x1e) or ESC+` — return to picker - if (rawSequence === "\x1e" || rawSequence === "\x1b`") { - switchToPicker() - return true - } - - // Ctrl+N — focus next pane - if (rawSequence === "\x0e") { - sessionGrid?.focusNext() - updateGridHeader() - updateGridFooter() - return true - } - - // Ctrl+P — focus previous pane - if (rawSequence === "\x10") { - sessionGrid?.focusPrev() - updateGridHeader() - updateGridFooter() - return true - } - - // Ctrl+W — close focused pane - if (rawSequence === "\x17") { - const pane = sessionGrid?.focusedPane - if (pane) { - const { killSession } = await import("./tmux/session-manager") - sessionGrid!.removeSession(pane.session.name) - await killSession(pane.session.name) - updateGridHeader() - updateGridFooter() - if (sessionGrid!.paneCount === 0) { - switchToPicker() - } - } - return true - } - - // Forward everything else to the focused tmux pane - if (sessionGrid) { - await sessionGrid.sendInputToFocused(rawSequence) - } - return true -} - -// ─── Main ─────────────────────────────────────────────────────────── async function main() { process.stdout.write("\x1b[2J\x1b[H") process.stdout.write("\x1b[1m cladm\x1b[0m\n") - if (demoMode) { + if (app.demoMode) { process.stdout.write("\x1b[2m [Demo mode] Loading mock projects...\x1b[0m\n") - projects = generateMockProjects() + app.projects = generateMockProjects() } else { process.stdout.write("\x1b[2m Loading projects...\x1b[0m\n") - projects = await discoverProjects() - if (projects.length === 0) { + app.projects = await discoverProjects() + if (app.projects.length === 0) { console.log(" No projects found in ~/.claude/history.jsonl") process.exit(1) } process.stdout.write( - `\x1b[2m Found ${projects.length} projects. Loading git metadata...\x1b[0m\n` + `\x1b[2m Found ${app.projects.length} projects. Loading git metadata...\x1b[0m\n` ) - await Promise.all(projects.map((p) => loadGitMetadata(p))) + await Promise.all(app.projects.map((p) => loadGitMetadata(p))) } - sortedIndices = projects.map((_, i) => i) + app.sortedIndices = app.projects.map((_, i) => i) rebuildDisplayRows() - renderer = await createCliRenderer({ + // Load saved session for restore hint + app.savedSession = await loadSavedSession() + + // Save raw stdout.write BEFORE OpenTUI intercepts it + app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean + + app.renderer = await createCliRenderer({ exitOnCtrlC: true, useAlternateScreen: true, - useMouse: true, + useMouse: false, onDestroy: () => { - destroyed = true - if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null } + app.destroyed = true + // Save session state before cleanup + try { + const state = extractSessionState() + if (state) saveSessionSync(state) + } catch (err) { console.error("[session-save]", err) } + if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null } + if (app.directGrid) app.directGrid.destroyAll() + stopAllCaptures() }, }) + // Enable mouse reporting manually (SGR mode for full coordinates) + process.stdout.write("\x1b[?1000h") + process.stdout.write("\x1b[?1006h") + // Build layout - mainBox = new BoxRenderable(renderer, { + app.mainBox = new BoxRenderable(app.renderer, { flexDirection: "column", width: "100%", height: "100%", }) - headerText = new TextRenderable(renderer, { + app.tabBarText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - colHeaderText = new TextRenderable(renderer, { + app.paneListText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - listBox = new ScrollBoxRenderable(renderer, { + app.headerText = new TextRenderable(app.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + + app.colHeaderText = new TextRenderable(app.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + + app.listBox = new ScrollBoxRenderable(app.renderer, { scrollY: true, flexGrow: 1, viewportCulling: true, }) - bottomRow = new BoxRenderable(renderer, { + app.bottomRow = new BoxRenderable(app.renderer, { flexDirection: "row", height: 10, flexShrink: 0, width: "100%", }) - previewBox = new BoxRenderable(renderer, { + app.previewBox = new BoxRenderable(app.renderer, { flexGrow: 1, height: "100%", borderStyle: "single", @@ -1004,14 +139,14 @@ async function main() { paddingLeft: 0, }) - previewText = new TextRenderable(renderer, { + app.previewText = new TextRenderable(app.renderer, { width: "100%", flexGrow: 1, wrapMode: "word", }) - previewBox.add(previewText) + app.previewBox.add(app.previewText) - usageBox = new BoxRenderable(renderer, { + app.usageBox = new BoxRenderable(app.renderer, { width: 34, height: "100%", flexShrink: 0, @@ -1025,126 +160,105 @@ async function main() { paddingRight: 1, }) - bottomRow.add(previewBox) - bottomRow.add(usageBox) + app.bottomRow.add(app.previewBox) + app.bottomRow.add(app.usageBox) - footerText = new TextRenderable(renderer, { + app.footerText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - mainBox.add(headerText) - mainBox.add(colHeaderText) - mainBox.add(listBox) - mainBox.add(bottomRow) - mainBox.add(footerText) + app.mainBox.add(app.tabBarText) + app.mainBox.add(app.paneListText) + app.mainBox.add(app.headerText) + app.mainBox.add(app.colHeaderText) + app.mainBox.add(app.listBox) + app.mainBox.add(app.bottomRow) + app.mainBox.add(app.footerText) - renderer.root.add(mainBox) + app.renderer.root.add(app.mainBox) - updateHeader() updateColumnHeaders() - rebuildList() - updateBottomPanel() updateUsagePanel() - updateFooter() + updateAll() // Load initial usage data getUsageSummary().then(u => { - cachedUsage = u + app.cachedUsage = u updateUsagePanel() - }).catch(() => {}) + }).catch(err => console.error("[usage]", err)) - // Intercept raw input for grid mode (before OpenTUI processes it) - renderer.prependInputHandler((sequence: string) => { - if (viewMode !== "grid") return false - // Handle grid input asynchronously, consume the event - handleGridInput(sequence) - return true + // Resize PTY panes when terminal window is resized + process.stdout.on("resize", () => { + if (app.viewMode !== "grid" || !app.directGrid) return + resizeGridPanes() }) - renderer.keyInput.on("keypress", handleKeypress) + // Take over stdin completely + process.stdin.removeAllListeners("data") + process.stdin.on("data", stdinHandler) // Live session monitoring - if (demoMode) { - 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) - } - prevBusySnapshot = snapshotBusy(projects) + if (app.demoMode) { + refreshMockSessions(app.projects) + app.prevBusySnapshot = snapshotBusy(app.projects) updateAll() } else { detectActiveSessions().then((sessions) => { - if (updateProjectSessions(projects, sessions)) updateAll() - prevBusySnapshot = snapshotBusy(projects) + if (updateProjectSessions(app.projects, sessions)) updateAll() + app.prevBusySnapshot = snapshotBusy(app.projects) }) } let usageTick = 0 - monitorInterval = setInterval(async () => { - if (destroyed) return + app.monitorInterval = setInterval(async () => { + if (app.destroyed) return - // Refresh usage every ~30s (6 ticks of 5s) usageTick++ if (usageTick % 6 === 0) { try { - cachedUsage = await getUsageSummary() + app.cachedUsage = await getUsageSummary() updateUsagePanel() - } catch {} + } catch (err) { console.error("[usage-poll]", err) } } - if (demoMode) { - for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 } - 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) - } - const transitioned = checkTransitions(projects, prevBusySnapshot) - prevBusySnapshot = snapshotBusy(projects) + if (app.demoMode) { + for (const p of app.projects) { p.activeSessions = 0; p.busySessions = 0 } + refreshMockSessions(app.projects) + const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) + app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { playDoneSound() bounceDock() - bottomPanelMode = "idle" + app.bottomPanelMode = "idle" } updateAll() } else { const sessions = await detectActiveSessions() - const changed = updateProjectSessions(projects, sessions) - const transitioned = checkTransitions(projects, prevBusySnapshot) - // Eagerly load/refresh session data for active projects (needed for idle panel) - for (const p of projects) { + const changed = updateProjectSessions(app.projects, sessions) + const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) + for (const p of app.projects) { if (p.activeSessions > 0 && (!p.sessions || transitioned.length > 0)) { p.sessions = await loadSessions(p.path) p.sessionCount = p.sessions.length } } - prevBusySnapshot = snapshotBusy(projects) + app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { playDoneSound() bounceDock() - bottomPanelMode = "idle" + app.bottomPanelMode = "idle" } if (changed) updateAll() - // Update grid pane statuses (flash idle sessions) - if (sessionGrid && viewMode === "grid") { + if (app.directGrid && app.viewMode === "grid") { await refreshAlive() for (const [, s] of getSessions()) { - // Use monitor.ts to check busy/idle const status = getSessionStatus(s.projectPath, s.sessionId) - if (status === "idle") sessionGrid.markIdle(s.name) - else if (status === "busy") sessionGrid.markBusy(s.name) - else sessionGrid.clearMark(s.name) + if (status === "idle") app.directGrid.markIdle(s.name) + else if (status === "busy") app.directGrid.markBusy(s.name) + else app.directGrid.clearMark(s.name) } } } diff --git a/src/input/handlers.ts b/src/input/handlers.ts new file mode 100644 index 0000000..60489eb --- /dev/null +++ b/src/input/handlers.ts @@ -0,0 +1,900 @@ +import type { KeyEvent } from "@opentui/core" +import { app } from "../lib/state" +import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels" +import { extractKeyboardInput, extractMouseEvents } from "./parser" +import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch" +import { doLaunch, doAddPane } 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, populateMockSessionStatus } from "../data/monitor" +import { stopAllCaptures } from "../pty/capture" +import type { DisplayRow } from "../lib/types" +import { extractSessionState, saveSessionSync, restoreSession } from "../data/session-store" + +// ─── Constants ─────────────────────────────────────────────────────── + +const SHIFT_ARROWS: Record = { + "\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", +} + +const KEY_MAP: Record = { + "\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(set: Set, item: T) { + if (set.has(item)) set.delete(item) + else set.add(item) +} + +const MAX_TAB_NUM = 9 + +function toggleRowSelection(row: DisplayRow) { + const project = app.projects[row.projectIndex] + if (row.type === "project" || row.type === "new-session") { + // Cycle tab number: none → 1 → 2 → ... → 9 → none + const current = app.selectedProjects.get(project.path) + if (current === undefined) { + app.selectedProjects.set(project.path, 1) + } else if (current < MAX_TAB_NUM) { + app.selectedProjects.set(project.path, current + 1) + } else { + app.selectedProjects.delete(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 assignTabNumber(row: DisplayRow, tabNum: number) { + const project = app.projects[row.projectIndex] + if (row.type === "project" || row.type === "new-session") { + const current = app.selectedProjects.get(project.path) + if (current === tabNum) { + app.selectedProjects.delete(project.path) // toggle off if same number + } else { + app.selectedProjects.set(project.path, tabNum) + } + } +} + +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) { + 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[] = [] + 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() +} + +// ─── 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 + if (relY >= y && relY < y + h) return i + y += h + } + return -1 +} + +// ─── Tab switching helpers ─────────────────────────────────────────── + +function handleTabSwitch(tabNumber: number) { + if (tabNumber === 0) { + // Switch to picker + if (app.viewMode === "grid") switchToPicker() + return + } + // tabNumber 1-9 → grid tab index 0-8 + const tabIndex = tabNumber - 1 + if (tabIndex < app.gridTabs.length) { + switchToGridTab(app.gridTabs[tabIndex].id) + } +} + +function handleNextTab() { + if (app.gridTabs.length === 0) return + if (app.viewMode === "picker") { + switchToGridTab(app.gridTabs[0].id) + return + } + const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId) + if (currentIdx < app.gridTabs.length - 1) { + switchToGridTab(app.gridTabs[currentIdx + 1].id) + } else { + switchToPicker() // wrap around to picker + } +} + +function handlePrevTab() { + if (app.gridTabs.length === 0) return + if (app.viewMode === "picker") { + switchToGridTab(app.gridTabs[app.gridTabs.length - 1].id) + return + } + const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId) + if (currentIdx > 0) { + switchToGridTab(app.gridTabs[currentIdx - 1].id) + } else { + switchToPicker() // wrap to picker + } +} + +// ─── Picker click ──────────────────────────────────────────────────── + +export function handlePickerClick(_col: number, screenRow: number) { + const idx = hitTestListRow(screenRow) + if (idx < 0 || idx >= app.displayRows.length) return + app.cursor = idx + toggleRowSelection(app.displayRows[idx]) + updateAll() +} + +// ─── Picker tab bar click ──────────────────────────────────────────── + +function handlePickerTabBarClick(col: number, screenRow: number) { + // Tab bar is at row 1 in picker (rendered as OpenTUI text) + if (screenRow !== 1) return false + // Hit test against tab bar positions — Chrome-style layout + // Picker: ╭ ● Picker ╮ = cols 1..10 (active) or ○ Picker = cols 1..10 + let c = 1 + const pickerEnd = c + 10 + if (col >= c && col <= pickerEnd) return false // already on picker + c = 11 + + for (const tab of app.gridTabs) { + const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id + + // Build inline pane name list to calculate width + const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? [] + const paneNames = tabPanes.map(p => { + const name = p.session.projectName + return name.length > 14 ? name.slice(0, 12) + "…" : name + }) + const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty" + const visLen = 2 + inlineLabel.length // "● " + label + + const dg = app.directGrid + + if (isActive) { + // Active: ╭ ● panes × ╮ + const labelStart = c + 2 + const labelEnd = labelStart + visLen - 1 + const closeCol = labelEnd + 2 + const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1 + + if (col === closeCol && dg) { + const result = dg.requestCloseTab(tab.id) + if (result === "closed") updateAll() + else { updateTabBar(); app.renderer.requestRender() } + return true + } + if (col >= labelStart && col <= labelEnd) { + switchToGridTab(tab.id) + return true + } + c += totalVis + } else { + // Inactive: sp ● panes sp × sp │ + const labelStart = c + 1 + const labelEnd = labelStart + visLen - 1 + const closeCol = labelEnd + 2 + const totalVis = 1 + visLen + 1 + 1 + 1 + 1 + + if (col === closeCol && dg) { + const result = dg.requestCloseTab(tab.id) + if (result === "closed") updateAll() + else { updateTabBar(); app.renderer.requestRender() } + return true + } + if (col >= labelStart && col <= labelEnd) { + switchToGridTab(tab.id) + return true + } + c += totalVis + } + } + + // [+] button + if (col >= c + 1 && col <= c + 3) { + createNewGridTab() + return true + } + + return false +} + +// ─── Keyboard ──────────────────────────────────────────────────────── + +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" && !app.projects[row.projectIndex].expanded) { + expandProject(row.projectIndex) + return + } + return + } + + case "left": + collapseProject(app.displayRows[app.cursor].projectIndex) + break + + case "space": + toggleRowSelection(app.displayRows[app.cursor]) + break + + case "f": { + const project = app.projects[app.displayRows[app.cursor].projectIndex] + Bun.spawn(["open", project.path]) + break + } + + case "g": { + const row = app.displayRows[app.cursor] + const project = app.projects[row.projectIndex] + + // Try grid pane navigation first + if (app.directGrid && app.gridTabs.length > 0) { + const targetSessionId = row.type === "session" && project.sessions + ? project.sessions[row.sessionIndex!]?.id + : undefined + + for (const tab of app.gridTabs) { + const panes = app.directGrid.getTabPanes(tab.id) + const paneIdx = targetSessionId + ? panes.findIndex(p => p.session.projectPath === project.path && p.session.sessionId === targetSessionId) + : panes.findIndex(p => p.session.projectPath === project.path) + if (paneIdx >= 0) { + switchToGridTab(tab.id) + app.directGrid.setFocus(paneIdx) + return + } + } + } + + // Fallback: external terminal + 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.set(p.path, 1) + 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) { + 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 + + 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) { + if (app.addPaneTargetTabId !== null) { + doAddPane() + } else { + doLaunch() + } + break + } + if (app.addPaneTargetTabId !== null) { + // In add-pane mode with no explicit selections, add cursor item + const addRow = app.displayRows[app.cursor] + if (addRow) app.selectedProjects.set(app.projects[addRow.projectIndex].path, 1) + doAddPane() + break + } + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) { + 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) { + if (await focusTerminalByPath(app.projects[returnRow.projectIndex].path)) return + } + doLaunch() + break + } + + case "o": { + if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) { + const oRow = app.displayRows[app.cursor] + if (oRow) app.selectedProjects.set(app.projects[oRow.projectIndex].path, 1) + } + 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 "1": case "2": case "3": case "4": case "5": + case "6": case "7": case "8": case "9": { + const row = app.displayRows[app.cursor] + assignTabNumber(row, parseInt(key.name)) + break + } + + case "r": { + if (app.restoreMode === "pending") { + // Second press: restore with resume + const saved = app.savedSession + if (saved) { + app.restoreMode = null + await restoreSession(saved, true) + return + } + } else if (app.savedSession) { + app.restoreMode = "pending" + } + break + } + + case "R": { + if (app.restoreMode === "pending") { + // Shift+R: restore fresh (no sessionIds) + const saved = app.savedSession + if (saved) { + app.restoreMode = null + await restoreSession(saved, false) + return + } + } + break + } + + case "escape": + if (app.addPaneTargetTabId !== null) { + // Cancel add-pane mode, return to grid + const returnTabId = app.addPaneTargetTabId + app.addPaneTargetTabId = null + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + switchToGridTab(returnTabId) + return + } + if (app.restoreMode === "pending") { + app.restoreMode = null + break + } + // fall through to quit + case "q": + app.destroyed = true + if (app.monitorInterval) clearInterval(app.monitorInterval) + // Save session before exit + try { + const state = extractSessionState() + if (state) saveSessionSync(state) + } catch {} + stopAllCaptures() + process.stdout.write("\x1b[?1006l") + process.stdout.write("\x1b[?1000l") + app.renderer.destroy() + return + + default: + return + } + + updateAll() + } catch (err) { console.error("[handleKeypress]", err) } +} + +// ─── Grid input ────────────────────────────────────────────────────── + +export async function handleGridInput(rawSequence: string): Promise { + if (app.viewMode !== "grid" || !app.directGrid) return false + + // Esc: collapse expanded/soft-expanded, or do nothing + if (rawSequence === "\x1b") { + if (app.directGrid.isExpanded) { app.directGrid.collapsePane(); return true } + if (app.directGrid.isSoftExpanded) { app.directGrid.softCollapsePane(); return true } + return true + } + + // Ctrl+Space → switch to picker + if (rawSequence === "\x00") { + app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === app.directGrid!.activeTabId) + switchToPicker() + return true + } + + // Ctrl+T → new tab + if (rawSequence === "\x14") { + createNewGridTab() + return true + } + + // Ctrl+S → toggle select mode (disable mouse tracking for native text selection) + if (rawSequence === "\x13") { + if (app.directGrid.selectMode) app.directGrid.exitSelectMode() + else app.directGrid.enterSelectMode() + return true + } + + // Ctrl+E → toggle click-to-expand + if (rawSequence === "\x05") { + app.clickExpand = !app.clickExpand + if (!app.clickExpand && app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane() + app.directGrid.drawChrome() + return true + } + + // Alt+1 through Alt+9 → switch tab + if (rawSequence.length === 2 && rawSequence[0] === "\x1b" && rawSequence[1] >= "1" && rawSequence[1] <= "9") { + handleTabSwitch(parseInt(rawSequence[1])) + return true + } + + // Alt+n → next tab, Alt+p → prev tab + if (rawSequence === "\x1bn") { handleNextTab(); return true } + if (rawSequence === "\x1bp") { handlePrevTab(); return true } + + // Ctrl+N → add pane to current tab (enter picker in add-pane mode) + if (rawSequence === "\x0e") { + app.addPaneTargetTabId = app.directGrid.activeTabId + switchToPicker() + return true + } + // Ctrl+P → focus prev pane + if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true } + + // Ctrl+F → open folder + if (rawSequence === "\x06") { + const pane = app.directGrid.focusedPane + if (pane) Bun.spawn(["open", pane.session.projectPath]) + return true + } + + // Ctrl+W → close pane (remove tab if last pane) + if (rawSequence === "\x17") { + const pane = app.directGrid.focusedPane + if (pane) { + if (app.directGrid.isExpanded) app.directGrid.collapsePane() + if (app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane() + const { killSession } = await import("../pty/session-manager") + app.directGrid.removePane(pane.session.name) + await killSession(pane.session.name) + if (app.directGrid.paneCount === 0) { + // Remove current tab and switch to previous or picker + const currentTabId = app.directGrid.activeTabId + const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId) + app.directGrid.removeTab(currentTabId) + app.gridTabs.splice(tabIdx, 1) + if (app.gridTabs.length > 0) { + const prevIdx = Math.max(0, tabIdx - 1) + switchToGridTab(app.gridTabs[prevIdx].id) + } else { + switchToPicker() + } + } + } + return true + } + + // Page Up/Down → scroll + 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" + app.activeTabIndex = 0 + if (app.directGrid) { + if (app.directGrid.selectMode) app.directGrid.exitSelectMode() + if (app.directGrid.totalPaneCount > 0) app.directGrid.pause() + } + 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() +} + +// ─── Double-click detection ────────────────────────────────────────── + +let _lastClickTime = 0 +let _lastClickCol = 0 +let _lastClickRow = 0 +const DOUBLE_CLICK_MS = 400 +const DOUBLE_CLICK_DIST = 2 + +function isDoubleClick(col: number, row: number): boolean { + const now = Date.now() + const dt = now - _lastClickTime + const dist = Math.abs(col - _lastClickCol) + Math.abs(row - _lastClickRow) + _lastClickTime = now + _lastClickCol = col + _lastClickRow = row + return dt < DOUBLE_CLICK_MS && dist <= DOUBLE_CLICK_DIST +} + +// ─── Stdin: grid mode ──────────────────────────────────────────────── + +function processGridInput(str: string) { + const dg = app.directGrid! + + if (dg.selectMode) { + if (extractKeyboardInput(str) === "\x1b") dg.exitSelectMode() + return + } + + 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) { + // Double-click → enter select mode for native text selection + if (isDoubleClick(me.col, me.row)) { dg.enterSelectMode(); return } + const btn = dg.checkButtonClick(me.col, me.row) + if (btn?.action === "closetab" && btn.tabId !== undefined) { + const result = dg.requestCloseTab(btn.tabId) + if (result === "closed") { + // Tab was closed — switch to adjacent or picker + if (app.gridTabs.length > 0) { + const currentTabId = dg.activeTabId + if (btn.tabId === currentTabId) { + // Closed the active tab — switch to first available + switchToGridTab(app.gridTabs[0].id) + } else { + dg.drawChrome() + } + } else { + switchToPicker() + } + } + } + else if (btn?.action === "closepane") { + dg.cancelPendingClose() + const pane = dg.paneCount > btn.paneIndex ? dg.getTabPanes(dg.activeTabId)[btn.paneIndex] : null + if (pane) { + if (dg.isExpanded) dg.collapsePane() + if (dg.isSoftExpanded) dg.softCollapsePane() + dg.removePane(pane.session.name) + if (dg.paneCount === 0) { + const currentTabId = dg.activeTabId + const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId) + dg.removeTab(currentTabId) + app.gridTabs.splice(tabIdx, 1) + if (app.gridTabs.length > 0) { + const prevIdx = Math.max(0, tabIdx - 1) + switchToGridTab(app.gridTabs[prevIdx].id) + } else { + switchToPicker() + } + } + } + } + else if (btn?.action === "max") { dg.cancelPendingClose(); dg.expandPane(btn.paneIndex) } + else if (btn?.action === "min") { dg.cancelPendingClose(); dg.collapsePane() } + else if (btn?.action === "sel") { dg.cancelPendingClose(); dg.enterSelectMode() } + else if (btn?.action === "tab") { + dg.cancelPendingClose() + if (btn.tabId === -1) { + // Switch to picker + app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId) + switchToPicker() + } else if (btn.tabId !== undefined) { + switchToGridTab(btn.tabId) + } + } + else if (btn?.action === "newtab") { dg.cancelPendingClose(); createNewGridTab() } + else if (btn?.action === "panefocus" && btn.tabId !== undefined) { + dg.cancelPendingClose() + // Click on pane name in pane list → switch to that tab and focus the pane + switchToGridTab(btn.tabId) + dg.setFocus(btn.paneIndex) + if (app.clickExpand) dg.softExpandPane(btn.paneIndex) + } + else { + dg.cancelPendingClose() + // Pane body click + if (app.clickExpand && !dg.isExpanded) { + const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row) + if (clickedIdx >= 0 && clickedIdx !== dg.focusIndex) { + dg.softExpandPane(clickedIdx) + } + } 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) { + // Ctrl+Space → toggle to last grid tab + if (str.includes("\x00")) { + if (app.directGrid && app.directGrid.totalPaneCount > 0) { + // Switch to last active grid tab + if (app.gridTabs.length > 0) { + const idx = Math.min(app.lastGridTabIndex, app.gridTabs.length - 1) + switchToGridTab(app.gridTabs[Math.max(0, idx)].id) + } + return + } + } + + const pickerMouse = extractMouseEvents(str) + for (const me of pickerMouse) { + if (me.btn === 0 && !me.release) { + if (handlePickerTabBarClick(me.col, me.row)) continue + 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 + + // Check for Alt+digit and Alt+n/p before normal key processing + let ki = 0 + while (ki < keyboard.length) { + // Alt sequences + if (keyboard[ki] === "\x1b" && ki + 1 < keyboard.length) { + const next = keyboard[ki + 1] + if (next >= "1" && next <= "9") { + handleTabSwitch(parseInt(next)) + ki += 2 + continue + } + if (next === "n") { handleNextTab(); ki += 2; continue } + if (next === "p") { handlePrevTab(); ki += 2; continue } + } + + let matched = false + for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { + const mapped = KEY_MAP[keyboard.slice(ki, ki + len)] + if (mapped) { + handleKeypress(syntheticKey(mapped.name, mapped.shift, mapped.ctrl)) + ki += len + matched = true + break + } + } + if (!matched) { + const code = keyboard.charCodeAt(ki) + if (code >= 0x21 && code <= 0x7e) { + handleKeypress(syntheticKey(keyboard[ki])) + } + ki++ + } + } +} + +// ─── Stdin buffering ───────────────────────────────────────────────── +// SGR mouse sequences (\x1b[ | null = null + +function dispatch(str: string) { + if (app.viewMode === "grid" && app.directGrid) processGridInput(str) + else processPickerInput(str) +} + +function flushPending() { + _timer = null + if (_pending) { + const p = _pending + _pending = "" + dispatch(p) + } +} + +// Returns index of a trailing partial escape sequence, or -1 if complete. +function trailingPartialEsc(data: string): number { + for (let i = data.length - 1; i >= 0 && i >= data.length - 30; i--) { + if (data.charCodeAt(i) !== 0x1b) continue + const ch = data[i + 1] + // Lone ESC at end + if (ch === undefined) return i + // CSI: \x1b[ — check for final byte + if (ch === "[") { + let j = i + 2 + while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3f) j++ + while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2f) j++ + if (j >= data.length) return i // no final byte yet — partial + continue + } + // OSC/DCS/APC/PM — need ST terminator + if (ch === "]" || ch === "P" || ch === "_" || ch === "^") { + let terminated = false + for (let j = i + 2; j < data.length; j++) { + if (data[j] === "\x07") { terminated = true; break } + if (data[j] === "\x1b" && data[j + 1] === "\\") { terminated = true; break } + } + if (!terminated) return i + continue + } + // SS3 (\x1bO) needs one more byte + if (ch === "O" && i + 2 >= data.length) return i + continue + } + return -1 +} + +// ─── Stdin entry point ─────────────────────────────────────────────── + +export function stdinHandler(data: string | Buffer) { + if (_timer) { clearTimeout(_timer); _timer = null } + const str = typeof data === "string" ? data : data.toString("utf8") + const full = _pending + str + _pending = "" + + const idx = trailingPartialEsc(full) + if (idx >= 0) { + _pending = full.slice(idx) + const ready = full.slice(0, idx) + if (ready) dispatch(ready) + _timer = setTimeout(flushPending, 8) + return + } + + dispatch(full) +} diff --git a/src/input/parser.ts b/src/input/parser.ts new file mode 100644 index 0000000..0d30633 --- /dev/null +++ b/src/input/parser.ts @@ -0,0 +1,104 @@ +// 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 + } + + // Alt+key combos: \x1b + printable/control char — pass through to PTY + // Includes Alt+Backspace (\x1b\x7f), Alt+digits, Alt+letters, etc. + const nc = data.charCodeAt(i + 1) + if ((nc >= 0x20 && nc <= 0x7e) || nc === 0x7f) { + keyboard += data.slice(i, i + 2) + 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 +} diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..4305f5a --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,67 @@ +import type { CliRenderer } from "@opentui/core" +import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core" +import type { Project, DisplayRow, SavedSession } 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 interface GridTab { + id: number + name: string +} + +export const app = { + // Config + demoMode: Bun.argv.includes("--demo"), + + // Data + projects: [] as Project[], + selectedProjects: new Map(), // path → tab number + selectedSessions: new Set(), + selectedBranches: new Map(), + cursor: 0, + sortMode: 0, + sortLabels: ["recent", "name", "commit", "sessions"] as const, + sortedIndices: [] as number[], + displayRows: [] as DisplayRow[], + + // Monitor + monitorInterval: null as ReturnType | null, + prevBusySnapshot: new Map(), + 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, + + // Tabs + activeTabIndex: 0, // 0 = picker, 1+ = grid tab index+1 + gridTabs: [] as GridTab[], // grid tabs only (not picker) + nextTabId: 1, // auto-increment for tab ids + clickExpand: true, // click-to-expand feature toggle + lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle + savedSession: null as SavedSession | null, + restoreMode: null as "pending" | null, + addPaneTargetTabId: null as number | null, + + // UI refs (set during init) + renderer: null as unknown as CliRenderer, + headerText: null as unknown as TextRenderable, + tabBarText: null as unknown as TextRenderable, + paneListText: 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, +} diff --git a/src/lib/styled.ts b/src/lib/styled.ts new file mode 100644 index 0000000..36f39c3 --- /dev/null +++ b/src/lib/styled.ts @@ -0,0 +1,20 @@ +import { StyledText } from "@opentui/core" + +type Chunk = { __isChunk: true; text: string; attributes: number; fg?: unknown; bg?: unknown } +type StyledPart = string | StyledText | Chunk + +// Concatenate styled text parts into a single StyledText. +// OpenTUI's t`` tag doesn't handle StyledText interpolation — it calls +// toString() which produces "[object Object]". This helper merges chunks +// from multiple t`` results, TextChunks, and plain strings. +export function st(...parts: StyledPart[]): StyledText { + const chunks: Chunk[] = [] + for (const p of parts) { + if (p instanceof StyledText) chunks.push(...p.chunks) + else if (p && typeof p === "object" && "__isChunk" in p) chunks.push(p as Chunk) + else if (typeof p === "string") { + if (p.length > 0) chunks.push({ __isChunk: true, text: p, attributes: 0 } as Chunk) + } + } + return new StyledText(chunks) +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..7aeb86a --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,4 @@ +export const CURSOR_BG = "#283457" +export const ACTIVE_BG = "#1a2e1a" +export const ACCENT = "#7aa2f7" +export const DIM_CLR = "#565f89" diff --git a/src/lib/time.ts b/src/lib/time.ts index 18078b6..7a88517 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -20,6 +20,15 @@ export function elapsedCompact(ms: number): string { return `${Math.floor(sec / 86400)}d` } +export function timeAgoShort(ms: number): string { + if (!ms) return "" + const diff = Math.floor((Date.now() - ms) / 1000) + if (diff < 60) return "0m ago" + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + return `${Math.floor(diff / 86400)}d ago` +} + export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` diff --git a/src/lib/types.ts b/src/lib/types.ts index 75110f5..06b6bda 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -46,3 +46,24 @@ export interface DisplayRow { sessionIndex?: number branchName?: string } + +export interface SavedPane { + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string +} + +export interface SavedTab { + id: number + name: string + panes: SavedPane[] +} + +export interface SavedSession { + version: 1 + savedAt: number + activeTabIndex: number + nextTabId: number + tabs: SavedTab[] +} diff --git a/src/pty/capture.ts b/src/pty/capture.ts new file mode 100644 index 0000000..b949f7c --- /dev/null +++ b/src/pty/capture.ts @@ -0,0 +1,705 @@ +// PTY capture: reads raw ANSI output from pty-helper stdout, +// maintains a virtual screen buffer, and pushes frame updates to subscribers. +// Replaces tmux capture-pane — no screen-scraping, direct PTY output. + +import type { PtySession } from "./session-manager" + +export interface CaptureResult { + lines: string[] + cursorX: number + cursorY: number + width: number + height: number +} + +type FrameCallback = (frame: CaptureResult) => void + +interface PaneState { + session: PtySession + screen: VtScreen + callbacks: Set + reader: ReadableStreamDefaultReader | null + running: boolean +} + +const panes = new Map() + +// ─── VT Screen Buffer ───────────────────────────────────────── + +interface VtCell { + char: string + sgr: string // accumulated SGR state as ANSI escape string +} + +class VtScreen { + width: number + height: number + cursorX = 0 + cursorY = 0 + cells: VtCell[][] + scrollback: VtCell[][] = [] // lines that scrolled off the top + scrollOffset = 0 // 0 = live view, >0 = scrolled back N lines + private static MAX_SCROLLBACK = 5000 + private currentSgr = "" + private savedCursorX = 0 + private savedCursorY = 0 + private scrollTop = 0 + private scrollBottom: number + private altScreen: VtCell[][] | null = null + private altCursorX = 0 + private altCursorY = 0 + + constructor(width: number, height: number) { + this.width = width + this.height = height + this.scrollBottom = height - 1 + this.cells = this.makeGrid(width, height) + } + + private makeGrid(w: number, h: number): VtCell[][] { + return Array.from({ length: h }, () => + Array.from({ length: w }, () => ({ char: " ", sgr: "" })) + ) + } + + resize(width: number, height: number) { + const newCells = this.makeGrid(width, height) + for (let r = 0; r < Math.min(height, this.height); r++) { + for (let c = 0; c < Math.min(width, this.width); c++) { + newCells[r][c] = this.cells[r]?.[c] ?? { char: " ", sgr: "" } + } + } + this.cells = newCells + this.width = width + this.height = height + this.scrollTop = 0 + this.scrollBottom = height - 1 + if (this.cursorX >= width) this.cursorX = width - 1 + if (this.cursorY >= height) this.cursorY = height - 1 + } + + // Write raw PTY output to the virtual screen + write(data: string) { + let i = 0 + while (i < data.length) { + const c = data.charCodeAt(i) + + // ESC sequences + if (c === 0x1b && i + 1 < data.length) { + const next = data[i + 1] + + // CSI: \x1b[ + if (next === "[") { + i = this.handleCSI(data, i + 2) + continue + } + + // OSC: \x1b] ... BEL or ST — skip + if (next === "]") { + i = this.skipOSC(data, i + 2) + continue + } + + // DCS/APC/PM: \x1bP, \x1b_, \x1b^ — skip to ST + if (next === "P" || next === "_" || next === "^") { + i = this.skipToST(data, i + 2) + continue + } + + // SS3: \x1bO — skip the next char + if (next === "O" && i + 2 < data.length) { + i += 3 + continue + } + + // Save cursor: \x1b7 or \x1b[s + if (next === "7") { + this.savedCursorX = this.cursorX + this.savedCursorY = this.cursorY + i += 2; continue + } + + // Restore cursor: \x1b8 or \x1b[u + if (next === "8") { + this.cursorX = this.savedCursorX + this.cursorY = this.savedCursorY + i += 2; continue + } + + // Index (scroll up): \x1bD + if (next === "D") { + this.index() + i += 2; continue + } + + // Reverse index (scroll down): \x1bM + if (next === "M") { + this.reverseIndex() + i += 2; continue + } + + // Set tab stop, reset: skip + if (next === "H" || next === "c") { + i += 2; continue + } + + // Unknown ESC — skip + i += 2; continue + } + + // C0 control characters + if (c === 0x0d) { // CR + this.cursorX = 0 + i++; continue + } + if (c === 0x0a) { // LF + if (this.cursorY === this.scrollBottom) { + this.scrollUp() + } else if (this.cursorY < this.height - 1) { + this.cursorY++ + } + i++; continue + } + if (c === 0x08) { // BS + if (this.cursorX > 0) this.cursorX-- + i++; continue + } + if (c === 0x09) { // TAB + this.cursorX = Math.min(((this.cursorX >> 3) + 1) << 3, this.width - 1) + i++; continue + } + if (c === 0x07) { // BEL + i++; continue + } + if (c < 0x20 && c !== 0x1b) { + i++; continue + } + + // Printable character + if (this.cursorX >= this.width) { + // Auto-wrap + this.cursorX = 0 + if (this.cursorY === this.scrollBottom) { + this.scrollUp() + } else if (this.cursorY < this.height - 1) { + this.cursorY++ + } + } + + const row = this.cells[this.cursorY] + if (row && this.cursorX < this.width) { + row[this.cursorX] = { char: data[i], sgr: this.currentSgr } + } + this.cursorX++ + i++ + } + } + + // Get full buffer: all scrollback lines + current screen (for select mode) + getAllLines(): string[] { + const lines: string[] = [] + for (const row of this.scrollback) lines.push(this.renderRow(row)) + for (let r = 0; r < this.height; r++) lines.push(this.renderRow(this.cells[r])) + return lines + } + + // Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e) + // When scrollOffset > 0, shows scrollback history mixed with screen content + getLines(): string[] { + const lines: string[] = [] + + if (this.scrollOffset > 0) { + // Viewing scrollback: combine scrollback + current screen, then take a window + const sbLen = this.scrollback.length + const totalRows = sbLen + this.height + const startRow = totalRows - this.height - this.scrollOffset + for (let r = 0; r < this.height; r++) { + const srcRow = startRow + r + let row: VtCell[] + if (srcRow < 0) { + // Beyond scrollback — empty line + lines.push("") + continue + } else if (srcRow < sbLen) { + row = this.scrollback[srcRow] + } else { + row = this.cells[srcRow - sbLen] + } + lines.push(this.renderRow(row)) + } + } else { + // Live view: just render current screen + for (let r = 0; r < this.height; r++) { + lines.push(this.renderRow(this.cells[r])) + } + } + + return lines + } + + private renderRow(row: VtCell[]): string { + if (!row) return "" + let line = "" + let lastSgr = "" + let trailingSpaces = 0 + + for (let c = 0; c < this.width && c < row.length; c++) { + const cell = row[c] + if (cell.sgr !== lastSgr) { + if (trailingSpaces > 0) { + line += " ".repeat(trailingSpaces) + trailingSpaces = 0 + } + line += cell.sgr ? `\x1b[${cell.sgr}m` : "\x1b[0m" + lastSgr = cell.sgr + } + if (cell.char === " " && !cell.sgr) { + trailingSpaces++ + } else { + if (trailingSpaces > 0) { + line += " ".repeat(trailingSpaces) + trailingSpaces = 0 + } + line += cell.char + } + } + if (lastSgr) line += "\x1b[0m" + return line + } + + // ─── CSI Handler ────────────────────────────────────────── + + private handleCSI(data: string, start: number): number { + let i = start + const params: number[] = [] + let num = "" + let privateMode = "" + + // Check for private mode prefix (?, >, =) + if (i < data.length && (data[i] === "?" || data[i] === ">" || data[i] === "=")) { + privateMode = data[i] + i++ + } + + // Parse parameters + while (i < data.length) { + const ch = data[i] + if (ch >= "0" && ch <= "9") { + num += ch; i++ + } else if (ch === ";") { + params.push(num === "" ? 0 : parseInt(num)) + num = ""; i++ + } else { + params.push(num === "" ? 0 : parseInt(num)) + break + } + } + + if (i >= data.length) return i + + const final = data[i] + i++ + + // Private mode sequences (h/l for set/reset) + if (privateMode === "?") { + if (final === "h" || final === "l") { + const mode = params[0] ?? 0 + if (mode === 1049 || mode === 47 || mode === 1047) { + if (final === "h") { + // Enter alternate screen + this.altScreen = this.cells + this.altCursorX = this.cursorX + this.altCursorY = this.cursorY + this.cells = this.makeGrid(this.width, this.height) + this.cursorX = 0 + this.cursorY = 0 + } else { + // Leave alternate screen + if (this.altScreen) { + this.cells = this.altScreen + this.cursorX = this.altCursorX + this.cursorY = this.altCursorY + this.altScreen = null + } + } + } + // Ignore other private modes (cursor visibility, mouse, etc.) + } + return i + } + + // Standard CSI sequences + switch (final) { + case "m": // SGR + this.currentSgr = this.buildSgrString(params) + break + + case "H": case "f": { // Cursor position + const row = Math.max(0, (params[0] || 1) - 1) + const col = Math.max(0, (params[1] || 1) - 1) + this.cursorY = Math.min(row, this.height - 1) + this.cursorX = Math.min(col, this.width - 1) + break + } + + case "A": // Cursor up + this.cursorY = Math.max(0, this.cursorY - (params[0] || 1)) + break + case "B": // Cursor down + this.cursorY = Math.min(this.height - 1, this.cursorY + (params[0] || 1)) + break + case "C": // Cursor right + this.cursorX = Math.min(this.width - 1, this.cursorX + (params[0] || 1)) + break + case "D": // Cursor left + this.cursorX = Math.max(0, this.cursorX - (params[0] || 1)) + break + + case "G": // Cursor horizontal absolute + this.cursorX = Math.min(Math.max(0, (params[0] || 1) - 1), this.width - 1) + break + case "d": // Cursor vertical absolute + this.cursorY = Math.min(Math.max(0, (params[0] || 1) - 1), this.height - 1) + break + + case "J": { // Erase in display + const mode = params[0] || 0 + if (mode === 0) { // Erase below + this.clearRange(this.cursorY, this.cursorX, this.height - 1, this.width - 1) + } else if (mode === 1) { // Erase above + this.clearRange(0, 0, this.cursorY, this.cursorX) + } else if (mode === 2 || mode === 3) { // Erase all + this.cells = this.makeGrid(this.width, this.height) + } + break + } + + case "K": { // Erase in line + const mode = params[0] || 0 + const row = this.cells[this.cursorY] + if (!row) break + if (mode === 0) { // Erase to right + for (let c = this.cursorX; c < this.width; c++) row[c] = { char: " ", sgr: "" } + } else if (mode === 1) { // Erase to left + for (let c = 0; c <= this.cursorX; c++) row[c] = { char: " ", sgr: "" } + } else if (mode === 2) { // Erase entire line + for (let c = 0; c < this.width; c++) row[c] = { char: " ", sgr: "" } + } + break + } + + case "X": { // Erase characters + const n = params[0] || 1 + const row = this.cells[this.cursorY] + if (row) { + for (let c = this.cursorX; c < Math.min(this.cursorX + n, this.width); c++) { + row[c] = { char: " ", sgr: "" } + } + } + break + } + + case "L": { // Insert lines + const n = Math.min(params[0] || 1, this.scrollBottom - this.cursorY + 1) + for (let j = 0; j < n; j++) { + this.cells.splice(this.scrollBottom, 1) + this.cells.splice(this.cursorY, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + } + break + } + + case "M": { // Delete lines + const n = Math.min(params[0] || 1, this.scrollBottom - this.cursorY + 1) + for (let j = 0; j < n; j++) { + this.cells.splice(this.cursorY, 1) + this.cells.splice(this.scrollBottom, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + } + break + } + + case "P": { // Delete characters + const n = params[0] || 1 + const row = this.cells[this.cursorY] + if (row) { + row.splice(this.cursorX, n) + while (row.length < this.width) row.push({ char: " ", sgr: "" }) + } + break + } + + case "@": { // Insert characters + const n = params[0] || 1 + const row = this.cells[this.cursorY] + if (row) { + for (let j = 0; j < n; j++) { + row.splice(this.cursorX, 0, { char: " ", sgr: "" }) + } + row.length = this.width + } + break + } + + case "S": { // Scroll up + const n = params[0] || 1 + for (let j = 0; j < n; j++) this.scrollUp() + break + } + + case "T": { // Scroll down + const n = params[0] || 1 + for (let j = 0; j < n; j++) this.reverseIndex() + break + } + + case "r": { // Set scroll region + this.scrollTop = Math.max(0, (params[0] || 1) - 1) + this.scrollBottom = Math.min(this.height - 1, (params[1] || this.height) - 1) + this.cursorX = 0 + this.cursorY = 0 + break + } + + case "s": // Save cursor + this.savedCursorX = this.cursorX + this.savedCursorY = this.cursorY + break + case "u": // Restore cursor + this.cursorX = this.savedCursorX + this.cursorY = this.savedCursorY + break + + case "n": // Device status report — ignore + case "c": // Device attributes — ignore + case "h": case "l": // Set/reset mode — ignore + case "t": // Window manipulation — ignore + break + } + + return i + } + + private buildSgrString(params: number[]): string { + // Reset + if (params.length === 1 && params[0] === 0) return "" + if (params.length === 0) return "" + return params.join(";") + } + + private clearRange(r1: number, c1: number, r2: number, c2: number) { + for (let r = r1; r <= r2 && r < this.height; r++) { + const row = this.cells[r] + if (!row) continue + const startC = r === r1 ? c1 : 0 + const endC = r === r2 ? c2 : this.width - 1 + for (let c = startC; c <= endC && c < this.width; c++) { + row[c] = { char: " ", sgr: "" } + } + } + } + + private scrollUp() { + // Save the line scrolling off the top into scrollback + const removedRow = this.cells.splice(this.scrollTop, 1)[0] + if (removedRow && this.scrollTop === 0) { + this.scrollback.push(removedRow) + if (this.scrollback.length > VtScreen.MAX_SCROLLBACK) { + this.scrollback.shift() + } + } + this.cells.splice(this.scrollBottom, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + // Auto-reset scroll offset when new output arrives + if (this.scrollOffset > 0) this.scrollOffset = 0 + } + + private index() { + if (this.cursorY === this.scrollBottom) { + this.scrollUp() + } else if (this.cursorY < this.height - 1) { + this.cursorY++ + } + } + + private reverseIndex() { + if (this.cursorY === this.scrollTop) { + this.cells.splice(this.scrollBottom, 1) + this.cells.splice(this.scrollTop, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + } else if (this.cursorY > 0) { + this.cursorY-- + } + } + + private skipOSC(data: string, start: number): number { + let i = start + while (i < data.length) { + if (data[i] === "\x07") return i + 1 + if (data[i] === "\x1b" && i + 1 < data.length && data[i + 1] === "\\") return i + 2 + i++ + } + return i + } + + private skipToST(data: string, start: number): number { + let i = start + while (i < data.length) { + if (data[i] === "\x1b" && i + 1 < data.length && data[i + 1] === "\\") return i + 2 + i++ + } + return i + } +} + +// ─── Public API ───────────────────────────────────────────── + +export function startCapture(session: PtySession): void { + if (panes.has(session.name)) return + + const screen = new VtScreen(session.width, session.height) + const state: PaneState = { + session, + screen, + callbacks: new Set(), + reader: null, + running: true, + } + panes.set(session.name, state) + + // Start reading stdout from pty-helper + if (session.proc.stdout) { + const reader = session.proc.stdout.getReader() + state.reader = reader + const decoder = new TextDecoder() + + ;(async () => { + try { + while (state.running) { + const { done, value } = await reader.read() + if (done) break + const text = decoder.decode(value, { stream: true }) + screen.write(text) + + // Push frame to subscribers + const frame: CaptureResult = { + lines: screen.getLines(), + cursorX: screen.cursorX, + cursorY: screen.cursorY, + width: screen.width, + height: screen.height, + } + for (const cb of state.callbacks) { + try { cb(frame) } catch {} + } + } + } catch { + // Process died + } + })() + } +} + +export function onFrame(sessionName: string, cb: FrameCallback): () => void { + const state = panes.get(sessionName) + if (state) state.callbacks.add(cb) + return () => { + const s = panes.get(sessionName) + if (s) s.callbacks.delete(cb) + } +} + +export function getLatestFrame(sessionName: string): CaptureResult | null { + const state = panes.get(sessionName) + if (!state) return null + return { + lines: state.screen.getLines(), + cursorX: state.screen.cursorX, + cursorY: state.screen.cursorY, + width: state.screen.width, + height: state.screen.height, + } +} + +export function getFullBuffer(sessionName: string): string[] | null { + const state = panes.get(sessionName) + if (!state) return null + return state.screen.getAllLines() +} + +export function stopCapture(sessionName: string): void { + const state = panes.get(sessionName) + if (!state) return + state.running = false + state.callbacks.clear() + if (state.reader) { + try { state.reader.cancel() } catch {} + } + panes.delete(sessionName) +} + +export function resizeCapture(sessionName: string, width: number, height: number): void { + const state = panes.get(sessionName) + if (!state) return + state.screen.resize(width, height) +} + +// Scroll the pane's view into scrollback history. +// Returns the new scroll offset (0 = live view). +export function scrollPane(sessionName: string, direction: "up" | "down", lines = 5): number { + const state = panes.get(sessionName) + if (!state) return 0 + const screen = state.screen + const maxOffset = screen.scrollback.length + + if (direction === "up") { + screen.scrollOffset = Math.min(screen.scrollOffset + lines, maxOffset) + } else { + screen.scrollOffset = Math.max(screen.scrollOffset - lines, 0) + } + + // Push a frame update so the pane redraws with the new scroll position + const frame: CaptureResult = { + lines: screen.getLines(), + cursorX: screen.cursorX, + cursorY: screen.cursorY, + width: screen.width, + height: screen.height, + } + for (const cb of state.callbacks) { + try { cb(frame) } catch {} + } + + return screen.scrollOffset +} + +export function getScrollOffset(sessionName: string): number { + const state = panes.get(sessionName) + return state?.screen.scrollOffset ?? 0 +} + +export function stopAllCaptures(): void { + for (const [name] of panes) stopCapture(name) +} + +// Hash for diffing — skip re-render if nothing changed +const lastHashes = new Map() + +export function hasChanged(lines: string[], key = "_default"): boolean { + let h = 5381 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + for (let j = 0; j < line.length; j++) { + h = ((h << 5) + h + line.charCodeAt(j)) | 0 + } + h = ((h << 5) + h + 10) | 0 + } + const prev = lastHashes.get(key) + if (prev === h) return false + lastHashes.set(key, h) + return true +} + +export function resetHash(key = "_default") { + lastHashes.delete(key) +} diff --git a/src/pty/session-manager.ts b/src/pty/session-manager.ts new file mode 100644 index 0000000..5879f09 --- /dev/null +++ b/src/pty/session-manager.ts @@ -0,0 +1,142 @@ +// Direct PTY session management — no tmux. +// Each session spawns a pty-helper subprocess that owns a real PTY. +// I/O flows through Bun.spawn stdin/stdout pipes. + +import type { Subprocess } from "bun" +import { resolve } from "path" + +export interface PtySession { + name: string + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string + alive: boolean + width: number + height: number + colorIndex: number + proc: Subprocess<"pipe", "pipe", "pipe"> +} + +const sessions = new Map() +let colorCounter = 0 + +// Resolve pty-helper binary path relative to this source file +const PTY_HELPER = resolve(import.meta.dir, "..", "..", "bin", "pty-helper") + +export function getSessions(): Map { + return sessions +} + +export function getSessionByProject(projectPath: string): PtySession[] { + return [...sessions.values()].filter(s => s.projectPath === projectPath) +} + +export async function createSession(opts: { + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string + width: number + height: number +}): Promise { + const slug = opts.projectName.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 20) + const ts = Date.now().toString(36) + const name = `cladm-${slug}-${ts}` + + const cmd = buildClaudeCmd(opts.projectPath, opts.sessionId, opts.targetBranch) + + const proc = Bun.spawn([ + PTY_HELPER, + String(opts.height), + String(opts.width), + "bash", "-c", cmd, + ], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, TERM: "xterm-256color", CLAUDECODE: "" }, + }) + + // Assign color index based on project path (same project = same color) + const existingForProject = getSessionByProject(opts.projectPath) + const ci = existingForProject.length > 0 + ? existingForProject[0].colorIndex + : colorCounter++ + + const session: PtySession = { + name, + projectPath: opts.projectPath, + projectName: opts.projectName, + sessionId: opts.sessionId, + targetBranch: opts.targetBranch, + alive: true, + width: opts.width, + height: opts.height, + colorIndex: ci, + proc, + } + + sessions.set(name, session) + + // Monitor for exit + proc.exited.then(() => { + session.alive = false + }) + + return session +} + +export function killSession(name: string): void { + const session = sessions.get(name) + if (!session) return + if (!session.proc.killed) { + session.proc.stdin.end() + session.proc.kill() + } + session.alive = false + sessions.delete(name) +} + +export function resizeSession(name: string, width: number, height: number): void { + const session = sessions.get(name) + if (!session || !session.alive || session.proc.killed) return + session.width = width + session.height = height + // Send APC resize command: \x1b_R;\x1b\\ + const resizeCmd = `\x1b_R${height};${width}\x1b\\` + try { session.proc.stdin.write(resizeCmd) } catch {} +} + +export function writeToSession(name: string, data: string): void { + const session = sessions.get(name) + if (!session || !session.alive || session.proc.killed) return + try { session.proc.stdin.write(data) } catch {} +} + +export function isAlive(name: string): boolean { + const session = sessions.get(name) + if (!session) return false + return session.alive && !session.proc.killed +} + +export function refreshAlive(): void { + for (const [name, session] of sessions) { + if (session.proc.killed || !session.alive) { + sessions.delete(name) + } + } +} + +export function cleanupAll(): void { + for (const [name] of sessions) killSession(name) +} + +function buildClaudeCmd(path: string, sessionId?: string, targetBranch?: string): string { + const base = `cd '${path}' && claude --dangerously-skip-permissions` + const branchFlag = targetBranch + ? ` -p "switch to branch ${targetBranch}, stash if needed"` + : "" + if (sessionId) return `${base} --resume '${sessionId}'${branchFlag}` + return `${base}${branchFlag}` +} diff --git a/src/tmux/capture.ts b/src/tmux/capture.ts index 9134031..8f74fe0 100644 --- a/src/tmux/capture.ts +++ b/src/tmux/capture.ts @@ -1,3 +1,9 @@ +// Captures tmux pane content via persistent streaming subprocesses. +// Push-based: notifies subscribers immediately when new frames arrive. +// Zero polling overhead on the JS side — callbacks fire on stream data. + +import type { Subprocess } from "bun" + export interface CaptureResult { lines: string[] cursorX: number @@ -6,29 +12,157 @@ export interface CaptureResult { height: number } -export async function capturePane(sessionName: string): Promise { - try { - const [contentProc, infoProc] = [ - Bun.spawn(["tmux", "capture-pane", "-t", sessionName, "-p", "-e"], { stdout: "pipe", stderr: "ignore" }), - Bun.spawn(["tmux", "display-message", "-t", sessionName, "-p", "#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}"], { stdout: "pipe", stderr: "ignore" }), - ] +const SEP = "%%CLADM_FRAME%%" - const [contentText, infoText] = await Promise.all([ - new Response(contentProc.stdout).text(), - new Response(infoProc.stdout).text(), - ]) +type FrameCallback = (frame: CaptureResult) => void - const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited]) - if (codeContent !== 0 || codeInfo !== 0) return null +interface PaneCapture { + proc: Subprocess<"ignore", "pipe", "ignore"> + latest: CaptureResult | null + buf: string + callbacks: Set +} - const parts = infoText.trim().split(" ") +const panes = new Map() + +export function startCapture(sessionName: string, intervalMs = 100): void { + if (panes.has(sessionName)) return + + const script = `while true; do +tmux capture-pane -t '${sessionName}' -p -e 2>/dev/null +echo '${SEP}' +tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}' 2>/dev/null +echo '${SEP}END' +sleep ${(intervalMs / 1000).toFixed(3)} +done` + + const proc = Bun.spawn(["sh", "-c", script], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }) + + const state: PaneCapture = { proc, latest: null, buf: "", callbacks: new Set() } + panes.set(sessionName, state) + + const reader = proc.stdout.getReader() + const decoder = new TextDecoder() + + ;(async () => { + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + state.buf += decoder.decode(value, { stream: true }) + processBuffer(sessionName, state) + } + } catch { + // Process died + } + })() +} + +function processBuffer(sessionName: string, state: PaneCapture) { + while (true) { + const endMarker = SEP + "END" + const endIdx = state.buf.indexOf(endMarker) + if (endIdx < 0) break + + const frame = state.buf.slice(0, endIdx) + state.buf = state.buf.slice(endIdx + endMarker.length) + if (state.buf.startsWith("\n")) state.buf = state.buf.slice(1) + + const sepIdx = frame.indexOf(SEP) + if (sepIdx < 0) continue + + const contentText = frame.slice(0, sepIdx) + const infoText = frame.slice(sepIdx + SEP.length).trim() + + const parts = infoText.split(" ") + const cursorX = parseInt(parts[0]) || 0 + const cursorY = parseInt(parts[1]) || 0 + const width = parseInt(parts[2]) || 80 + const height = parseInt(parts[3]) || 24 + + const lines = contentText.split("\n") + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop() + + state.latest = { lines, cursorX, cursorY, width, height } + + // Push to all subscribers immediately + for (const cb of state.callbacks) { + try { cb(state.latest) } catch {} + } + } +} + +// Subscribe to frame updates. Returns unsubscribe function. +export function onFrame(sessionName: string, cb: FrameCallback): () => void { + const state = panes.get(sessionName) + if (state) state.callbacks.add(cb) + // Unsub looks up current state (handles setCaptureRate restarts) + return () => { + const s = panes.get(sessionName) + if (s) s.callbacks.delete(cb) + } +} + +export function getLatestFrame(sessionName: string): CaptureResult | null { + return panes.get(sessionName)?.latest ?? null +} + +export function stopCapture(sessionName: string): void { + const state = panes.get(sessionName) + if (!state) return + if (!state.proc.killed) state.proc.kill() + state.callbacks.clear() + panes.delete(sessionName) +} + +// Change capture rate without losing subscribers +export function setCaptureRate(sessionName: string, intervalMs: number): void { + const state = panes.get(sessionName) + if (!state) return + const savedCallbacks = new Set(state.callbacks) + stopCapture(sessionName) + startCapture(sessionName, intervalMs) + const newState = panes.get(sessionName) + if (newState) { + for (const cb of savedCallbacks) newState.callbacks.add(cb) + } +} + +export function stopAllCaptures(): void { + for (const [name] of panes) stopCapture(name) +} + +// Legacy one-shot capture (for non-grid use) +export async function capturePane(sessionName: string): Promise { + const latest = getLatestFrame(sessionName) + if (latest) return latest + + try { + const proc = Bun.spawn(["sh", "-c", + `tmux capture-pane -t '${sessionName}' -p -e && echo '${SEP}' && tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'` + ], { stdout: "pipe", stderr: "ignore" }) + + const text = await new Response(proc.stdout).text() + const code = await proc.exited + if (code !== 0) return null + + const sepIdx = text.lastIndexOf(SEP) + if (sepIdx < 0) return null + + const contentText = text.slice(0, sepIdx) + const infoText = text.slice(sepIdx + SEP.length).trim() + + const parts = infoText.split(" ") const cursorX = parseInt(parts[0]) || 0 const cursorY = parseInt(parts[1]) || 0 const width = parseInt(parts[2]) || 80 const height = parseInt(parts[3]) || 24 const lines = contentText.split("\n") - // Remove trailing empty line from split if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop() return { lines, cursorX, cursorY, width, height } @@ -37,17 +171,24 @@ export async function capturePane(sessionName: string): Promise() -export function hasChanged(lines: string[]): boolean { - // Simple fast hash: join first+last few lines + length - const h = lines.length + ":" + (lines[0] || "") + (lines[lines.length - 1] || "") - if (h === lastHash) return false - lastHash = h +export function hasChanged(lines: string[], key = "_default"): boolean { + let h = 5381 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + for (let j = 0; j < line.length; j++) { + h = ((h << 5) + h + line.charCodeAt(j)) | 0 + } + h = ((h << 5) + h + 10) | 0 + } + const prev = lastHashes.get(key) + if (prev === h) return false + lastHashes.set(key, h) return true } -export function resetHash() { - lastHash = "" +export function resetHash(key = "_default") { + lastHashes.delete(key) } diff --git a/src/tmux/input-bridge.ts b/src/tmux/input-bridge.ts index 01d912f..9b23eb9 100644 --- a/src/tmux/input-bridge.ts +++ b/src/tmux/input-bridge.ts @@ -1,31 +1,77 @@ -// Forwards raw terminal input sequences to a tmux session +// Forwards raw terminal input to tmux sessions via a persistent shell. +// Zero process-spawn overhead per keystroke — writes to stdin of a long-lived sh. -export async function sendKeys(sessionName: string, rawSequence: string): Promise { - // Use -l for literal text to avoid tmux key-name interpretation - // But special keys need to be sent without -l +import type { Subprocess } from "bun" + +let shell: Subprocess<"ignore", "pipe", "ignore"> | null = null + +function getShell() { + if (shell && !shell.killed) return shell + shell = Bun.spawn(["sh"], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }) + return shell +} + +export function sendKeys(sessionName: string, rawSequence: string): void { + const sh = getShell() const special = mapSpecialSequence(rawSequence) + let cmd: string if (special) { - const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, special], { - stdout: "ignore", stderr: "ignore", - }) - await proc.exited + cmd = `tmux send-keys -t '${sessionName}' ${special}\n` } else { - // Literal text - send as hex to avoid escaping issues - const hexBytes = [...rawSequence].map(c => { - const code = c.charCodeAt(0) - return code.toString(16).padStart(2, "0") - }) - const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, "-H", ...hexBytes], { - stdout: "ignore", stderr: "ignore", - }) - await proc.exited + const hex = [...rawSequence].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ") + cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n` } + + sh.stdin.write(cmd) +} + +// Forward mouse events to tmux as SGR escape sequences +export function sendMouseEvent(sessionName: string, x: number, y: number, btn: number, release: boolean): void { + const sh = getShell() + const end = release ? "m" : "M" + const seq = `\x1b[<${btn};${x};${y}${end}` + const hex = [...seq].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ") + const cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n` + sh.stdin.write(cmd) +} + +// Scroll a tmux pane using copy-mode (works with any application in the pane) +export function sendScroll(sessionName: string, direction: "up" | "down", lines = 3): void { + const sh = getShell() + if (direction === "up") { + // Enter copy mode (no-op if already in it) then scroll up + sh.stdin.write(`tmux copy-mode -t '${sessionName}' 2>/dev/null; tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-up 2>/dev/null\n`) + } else { + // Scroll down in copy mode; if we hit bottom, exit copy mode + sh.stdin.write(`tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-down 2>/dev/null\n`) + } +} + +// Exit copy mode (e.g., when user starts typing) +export function exitCopyMode(sessionName: string): void { + const sh = getShell() + sh.stdin.write(`tmux send-keys -t '${sessionName}' -X cancel 2>/dev/null\n`) +} + +export function cleanupInputQueue(_sessionName: string) { + // No per-session cleanup needed with shared shell +} + +export function destroyShell() { + if (shell && !shell.killed) { + shell.stdin.end() + shell.kill() + } + shell = null } // Map known ANSI escape sequences to tmux key names function mapSpecialSequence(seq: string): string | null { - // Common escape sequences -> tmux key names const MAP: Record = { "\r": "Enter", "\n": "Enter", diff --git a/src/tmux/session-manager.ts b/src/tmux/session-manager.ts index ad39888..3d33964 100644 --- a/src/tmux/session-manager.ts +++ b/src/tmux/session-manager.ts @@ -63,6 +63,12 @@ export async function createSession(opts: { } sessions.set(name, session) + + // Enable mouse mode so clicks/scrolls forward to the application + Bun.spawn(["tmux", "set", "-t", name, "mouse", "on"], { + stdout: "ignore", stderr: "ignore", + }) + return session } diff --git a/src/ui/formatters.ts b/src/ui/formatters.ts new file mode 100644 index 0000000..630a8e4 --- /dev/null +++ b/src/ui/formatters.ts @@ -0,0 +1,192 @@ +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 const TAB_COLORS = [ + cyan, // 1 + green, // 2 + yellow, // 3 + magenta, // 4 + (s: string) => fg("#ff9e64")(s), // 5 + (s: string) => fg("#7dcfff")(s), // 6 + (s: string) => fg("#bb9af7")(s), // 7 + (s: string) => fg("#73daca")(s), // 8 + (s: string) => fg("#b4f9f8")(s), // 9 +] + +function getGridTabBadges(projectPath: string): string { + if (!app.directGrid || app.gridTabs.length === 0) return "" + const badges: string[] = [] + for (const tab of app.gridTabs) { + const panes = app.directGrid.getTabPanes(tab.id) + if (panes.some(p => p.session.projectPath === projectPath)) { + const displayIdx = app.gridTabs.indexOf(tab) + 1 + const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]! + badges.push(color(`T${displayIdx}`)) + } + } + return badges.length > 0 ? badges.join("") + " " : "" +} + +function getSessionGridTabBadge(projectPath: string, sessionId: string): string { + if (!app.directGrid || app.gridTabs.length === 0) return "" + for (const tab of app.gridTabs) { + const panes = app.directGrid.getTabPanes(tab.id) + if (panes.some(p => p.session.projectPath === projectPath && p.session.sessionId === sessionId)) { + const displayIdx = app.gridTabs.indexOf(tab) + 1 + const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]! + return " " + color(`T${displayIdx}`) + } + } + return "" +} + +function fmtTabCheck(tabNum: number | undefined) { + if (tabNum === undefined) return " " + const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]! + return color(String(tabNum)) +} + +export function fmtProjectRow(project: import("../lib/types").Project, isSelected: number | undefined) { + 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 = fmtTabCheck(isSelected) + 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)) + + const gridBadge = getGridTabBadges(project.path) + return t` ${activeDot}${activeTag}${gridBadge}[${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("✓") : " " // sessions still use boolean check + 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)" + + const tabBadge = session.id ? getSessionGridTabBadge(project.path, session.id) : "" + + if (status === "busy") { + return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( + size.padEnd(7) + )} ${fg(ACCENT)('"' + title + '"')} ${green("running")}${tabBadge} + ${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")}${tabBadge} + ${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: number | undefined) { + const check = fmtTabCheck(isSelected) + 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)}` +} diff --git a/src/ui/panels.ts b/src/ui/panels.ts new file mode 100644 index 0000000..7285a45 --- /dev/null +++ b/src/ui/panels.ts @@ -0,0 +1,458 @@ +import { + Box, + Text, + t, + bold, + dim, + fg, + green, + yellow, + cyan, + magenta, + red, +} from "@opentui/core" +import { st } from "../lib/styled" +import { app } from "../lib/state" +import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme" +import { getSessionStatus, getIdleSessions } from "../data/monitor" +import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" +import { timeAgo, formatSize, elapsedCompact, timeAgoShort } from "../lib/time" +import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" + +// ─── Display rows ──────────────────────────────────────────────────── + +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() +} + +// ─── Tab bar ───────────────────────────────────────────────────────── + +const PANE_COLORS = [ + "#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7", + "#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8", +] + +export function updatePaneList() { + if (!app.paneListText) return + if (!app.directGrid || app.gridTabs.length === 0) { + app.paneListText.content = "" + return + } + + const parts: Parameters = [t` `] + let first = true + for (const tab of app.gridTabs) { + const tabPanes = app.directGrid.getTabPanes(tab.id) + if (tabPanes.length === 0) continue + + for (let pi = 0; pi < tabPanes.length; pi++) { + const pane = tabPanes[pi]! + const name = pane.session.projectName + const short = name.length > 14 ? name.slice(0, 12) + "…" : name + const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi + + // Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown + const statusIcon = pane.status === "busy" ? green("● ") + : pane.status === "idle" ? yellow("◉ ") + : dim("○ ") + + if (!first) parts.push(dim(" · ")) + parts.push(statusIcon) + parts.push(isFocused ? bold(short) : dim(short)) + first = false + } + parts.push(dim(" │ ")) + first = true + } + app.paneListText.content = st(...parts) +} + +export function updateTabBar() { + if (!app.tabBarText) return + + const pickerActive = app.viewMode === "picker" + const sep = dim(" │ ") + + // Chrome-style: active tab gets visual emphasis + const parts: Parameters = [] + if (pickerActive) { + parts.push(t` ${dim("╭")} ${cyan("●")} ${bold("Picker")} ${dim("╮")}`) + } else { + parts.push(t` ${dim("○ Picker")} `) + } + + // Grid tabs — inline pane names + for (const tab of app.gridTabs) { + const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false + const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id + const isPending = app.directGrid?.pendingCloseTabId === tab.id + const closeBtn = isPending ? t` ${red(bold("[●]"))}` : t` ${dim("[×]")}` + + // Build inline pane name list + const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? [] + const paneNames = tabPanes.map(p => { + const name = p.session.projectName + return name.length > 14 ? name.slice(0, 12) + "…" : name + }) + const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty" + + if (isActive) { + parts.push(dim("╭"), t` ${cyan("●")} ${bold(inlineLabel)}`, closeBtn, t` ${dim("╮")}`) + } else if (hasIdle) { + parts.push(t` ${yellow("◉")} ${dim(inlineLabel)}`, closeBtn, " ", sep) + } else { + parts.push(t` ${dim("○ " + inlineLabel)}`, closeBtn, " ", sep) + } + } + + parts.push(t` ${dim("[+]")}`) + app.tabBarText.content = st(...parts) +} + +// ─── Header / Footer ───────────────────────────────────────────────── + +export function updateHeader() { + const total = app.selectedProjects.size + app.selectedSessions.size + const modeLabel = app.demoMode ? " [DEMO]" : "" + + // Add-pane mode: show target tab context + if (app.addPaneTargetTabId !== null) { + const targetTab = app.gridTabs.find(t => t.id === app.addPaneTargetTabId) + const tabName = targetTab?.name ?? `Tab ${app.addPaneTargetTabId}` + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${cyan(bold(`Adding to: ${tabName}`))} │ ${String(total)} selected ${dim( + `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` + )}` + return + } + + // Count distinct tab groups + const tabGroups = new Set(app.selectedProjects.values()) + const tabNote = tabGroups.size > 1 ? ` → ${tabGroups.size} tabs` : "" + const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" + 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${tabNote}${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${tabNote}${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)}` +} + +export function updateFooter() { + const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : "" + + // Restore mode: show choice prompt + if (app.restoreMode === "pending") { + app.footerText.content = t` ${yellow("Restore session?")} ${dim("r resume │ R fresh │ esc cancel")}` + return + } + + // Saved session hint + let restoreHint = "" + if (app.savedSession) { + const ago = timeAgoShort(app.savedSession.savedAt) + const paneCount = app.savedSession.tabs.reduce((sum, t) => sum + t.panes.length, 0) + restoreHint = ` │ r restore (${paneCount}p, ${ago})` + } + + // Add-pane mode: simplified footer + if (app.addPaneTargetTabId !== null) { + app.footerText.content = t` ${dim("↑↓ nav │ space select │ → expand │ ← collapse │ enter add │ esc cancel")}` + return + } + + 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" + gridHint + restoreHint + )}` + } 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" + gridHint + restoreHint + )}` + } +} + +// ─── 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 + 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 })) +} + +function updateIdlePanel() { + app.cachedIdleSessions = getIdleSessions(app.projects) + const n = app.cachedIdleSessions.length + app.previewBox.title = ` Idle Sessions (${n}) — enter to focus ` + clearChildren(app.previewBox) + 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 { + clearChildren(app.previewBox) + app.previewBox.add(app.previewText) + app.bottomRow.height = 10 + app.previewBox.title = " Preview " + updatePreview() + } +} + +// ─── Usage panel ───────────────────────────────────────────────────── + +function usageBarColor(p: number) { + return p >= 80 ? yellow : p >= 50 ? cyan : green +} + +export function updateUsagePanel() { + if (app.destroyed) return + clearChildren(app.usageBox) + + 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() +} + +// ─── Preview panel ─────────────────────────────────────────────────── + +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 = st( + 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)}` + } +} + +// ─── 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 + let rowHeight = 1 + if (row.type === "project") { + content = fmtProjectRow(project, app.selectedProjects.get(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.get(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 vnode = renderRowContent(i) + const rid = app.listBox.add(vnode) + rowRenderableIds.push(rid as unknown as string) + } + + 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) + } +} + +// ─── 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 + updateTabBar() + updatePaneList() + updateHeader() + rebuildList() + updateBottomPanel() + updateFooter() +}