import { BoxRenderable, TextRenderable, type CliRenderer, RGBA, t, bold, dim, fg, } from "@opentui/core" import { TerminalView, getProjectColor } from "./terminal-view" import type { TmuxSession } from "../tmux/session-manager" 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 { private renderer: CliRenderer private container: BoxRenderable 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, subtitle?: ReturnType): GridPane { const color = getProjectColor(session.colorIndex) const colorRGBA = RGBA.fromHex(color) const borderBox = new BoxRenderable(this.renderer, { borderStyle: "rounded", border: true, borderColor: colorRGBA, flexGrow: 1, flexDirection: "column", overflow: "hidden", }) const titleText = new TextRenderable(this.renderer, { width: "100%", height: 1, flexShrink: 0, }) titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}` 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 - 4, 4), }) borderBox.add(titleText) borderBox.add(subtitleText) borderBox.add(termView) this.container.add(borderBox) const pane: GridPane = { session, termView, borderBox, titleText, subtitleText, status: null, statusSince: 0 } this.panes.push(pane) termView.attach(session) this.updateLayout() this.updateBorders() return pane } removeSession(sessionName: string) { const idx = this.panes.findIndex(p => p.session.name === sessionName) if (idx < 0) return const pane = this.panes[idx] pane.termView.detach() this.container.remove(pane.borderBox.id) this.panes.splice(idx, 1) this.clearFlash(sessionName) if (this._focusIndex >= this.panes.length) { this._focusIndex = Math.max(0, this.panes.length - 1) } this.updateLayout() this.updateBorders() } focusNext() { if (this.panes.length === 0) return this._focusIndex = (this._focusIndex + 1) % this.panes.length this.updateBorders() } focusPrev() { if (this.panes.length === 0) return this._focusIndex = (this._focusIndex - 1 + this.panes.length) % this.panes.length this.updateBorders() } focusByIndex(index: number) { if (index >= 0 && index < this.panes.length) { this._focusIndex = index this.updateBorders() } } flashFocused() { const pane = this.focusedPane if (!pane) return 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) startFlash(sessionName: string) { if (this.flashTimers.has(sessionName)) return const pane = this.panes.find(p => p.session.name === sessionName) if (!pane) return const color = getProjectColor(pane.session.colorIndex) const colorRGBA = RGBA.fromHex(color) const flashColor = RGBA.fromHex("#ff9e64") // orange flash let on = true const timer = setInterval(() => { pane.borderBox.borderColor = on ? flashColor : colorRGBA on = !on this.renderer.requestRender() }, 400) this.flashTimers.set(sessionName, timer) } clearFlash(sessionName: string) { const timer = this.flashTimers.get(sessionName) if (timer) { clearInterval(timer) this.flashTimers.delete(sessionName) } const pane = this.panes.find(p => p.session.name === sessionName) if (pane) { const color = getProjectColor(pane.session.colorIndex) pane.borderBox.borderColor = RGBA.fromHex(color) } } // Mark a session as needing user input 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) 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) 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) 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") pane.borderBox.borderStyle = "heavy" } else { // Check if flashing — don't override flash timer if (!this.flashTimers.has(pane.session.name)) { pane.borderBox.borderColor = RGBA.fromHex(color) } pane.borderBox.borderStyle = "rounded" } } this.renderer.requestRender() } private updateLayout() { const n = this.panes.length if (n === 0) return // Calculate grid: prefer wider layout // 1 pane: full, 2: side by side, 3-4: 2x2, 5-6: 3x2, etc. const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4 const rows = Math.ceil(n / cols) // Set container direction this.container.flexDirection = "column" this.container.flexWrap = "wrap" // For a proper grid, we'd need nested boxes. For now, use flex percentages. for (let i = 0; i < n; i++) { const pane = this.panes[i] pane.borderBox.width = `${Math.floor(100 / cols)}%` pane.borderBox.height = `${Math.floor(100 / rows)}%` } // Update container to row+wrap for grid this.container.flexDirection = "row" this.container.flexWrap = "wrap" } private calcPaneDims() { // Rough estimate based on terminal size const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 const n = Math.max(1, this.panes.length + 1) // +1 for the incoming pane const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3 const rows = Math.ceil(n / cols) return { w: Math.floor(termW / cols), h: Math.floor((termH - 4) / rows), // -4 for header+footer } } 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) { pane.termView.detach() this.container.remove(pane.borderBox.id) } this.panes = [] this._focusIndex = 0 } }