From 4132744a01a3dcd2a26d5008963db253482e40ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:37:35 +0000 Subject: [PATCH] feat: add tmux-based multi-terminal grid view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch Claude Code sessions inside embedded tmux panes instead of separate Terminal.app windows. Sessions are tiled in a grid layout with color-coded borders per project. Border flashing alerts when a session goes idle and needs input. Ctrl+` switches between picker and grid view. New modules: - src/tmux/session-manager.ts — tmux session lifecycle - src/tmux/ansi-parser.ts — ANSI escape code to cell grid parser - src/tmux/capture.ts — polls tmux capture-pane for rendering - src/tmux/input-bridge.ts — forwards keystrokes to tmux sessions - src/components/terminal-view.ts — FrameBuffer renderable for panes - src/components/session-grid.ts — tiled grid with flash effects Co-Authored-By: Claude Opus 4.6 --- src/components/session-grid.ts | 253 ++++++++++++++++++++++++++++++++ src/components/terminal-view.ts | 137 +++++++++++++++++ src/index.ts | 224 +++++++++++++++++++++++++++- src/tmux/ansi-parser.ts | 188 ++++++++++++++++++++++++ src/tmux/capture.ts | 53 +++++++ src/tmux/input-bridge.ts | 71 +++++++++ src/tmux/session-manager.ts | 120 +++++++++++++++ 7 files changed, 1041 insertions(+), 5 deletions(-) create mode 100644 src/components/session-grid.ts create mode 100644 src/components/terminal-view.ts create mode 100644 src/tmux/ansi-parser.ts create mode 100644 src/tmux/capture.ts create mode 100644 src/tmux/input-bridge.ts create mode 100644 src/tmux/session-manager.ts diff --git a/src/components/session-grid.ts b/src/components/session-grid.ts new file mode 100644 index 0000000..0304bdd --- /dev/null +++ b/src/components/session-grid.ts @@ -0,0 +1,253 @@ +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 } from "../tmux/input-bridge" + +export interface GridPane { + session: TmuxSession + termView: TerminalView + borderBox: BoxRenderable + titleText: TextRenderable +} + +export class SessionGrid { + private renderer: CliRenderer + private container: BoxRenderable + private panes: GridPane[] = [] + private _focusIndex = 0 + private flashTimers = new Map>() + + constructor(renderer: CliRenderer, container: BoxRenderable) { + this.renderer = renderer + this.container = container + } + + 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 { + 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)}`) : ""}` + + // Calculate pane size (leave room for border + title) + const dims = this.calcPaneDims() + const termView = new TerminalView(this.renderer, { + width: Math.max(dims.w - 2, 10), + height: Math.max(dims.h - 3, 4), + }) + + borderBox.add(titleText) + borderBox.add(termView) + this.container.add(borderBox) + + const pane: GridPane = { session, termView, borderBox, titleText } + 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() + } + } + + async sendInputToFocused(rawSequence: string) { + const pane = this.focusedPane + if (!pane) return + await sendKeys(pane.session.name, rawSequence) + } + + // 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 + this.startFlash(sessionName) + pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#e0af68")("NEEDS INPUT")}` + this.renderer.requestRender() + } + + markBusy(sessionName: string) { + const pane = this.panes.find(p => p.session.name === sessionName) + if (!pane) return + this.clearFlash(sessionName) + pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#9ece6a")("RUNNING")}` + this.renderer.requestRender() + } + + clearMark(sessionName: string) { + const pane = this.panes.find(p => p.session.name === sessionName) + if (!pane) return + this.clearFlash(sessionName) + 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)}`) : ""}` + 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) + + // 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() { + 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 + } +} diff --git a/src/components/terminal-view.ts b/src/components/terminal-view.ts new file mode 100644 index 0000000..901077d --- /dev/null +++ b/src/components/terminal-view.ts @@ -0,0 +1,137 @@ +import { + FrameBufferRenderable, + type FrameBufferOptions, + type RenderContext, + type OptimizedBuffer, + RGBA, + TextAttributes, +} from "@opentui/core" +import { capturePane, hasChanged, resetHash } from "../tmux/capture" +import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser" +import type { TmuxSession } from "../tmux/session-manager" + +// Per-project color palette for borders (distinct, visible on dark bg) +const PROJECT_COLORS = [ + "#7aa2f7", // blue + "#9ece6a", // green + "#e0af68", // yellow + "#f7768e", // red + "#bb9af7", // purple + "#7dcfff", // cyan + "#ff9e64", // orange + "#c0caf5", // white + "#73daca", // teal + "#b4f9f8", // mint +] + +export function getProjectColor(colorIndex: number): string { + return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length] +} + +export class TerminalView extends FrameBufferRenderable { + session: TmuxSession | null = null + private pollTimer: ReturnType | null = null + private lastFrame: ParsedFrame | null = null + private _focused = false + private _flashUntil = 0 // timestamp until which border flashes + private _idleSince = 0 + + constructor(ctx: RenderContext, options: FrameBufferOptions) { + super(ctx, options) + } + + get focused() { return this._focused } + set focused(v: boolean) { this._focused = v } + + get idleSince() { return this._idleSince } + + attach(session: TmuxSession) { + this.detach() + this.session = session + resetHash() + this.startPolling() + } + + detach() { + this.stopPolling() + this.session = null + this.lastFrame = null + } + + flash(durationMs = 2000) { + this._flashUntil = Date.now() + durationMs + } + + setIdle(sinceMs: number) { + this._idleSince = sinceMs + } + + clearIdle() { + this._idleSince = 0 + } + + private startPolling() { + if (this.pollTimer) return + this.pollTimer = setInterval(() => this.refresh(), 80) + } + + private stopPolling() { + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + } + + private async refresh() { + if (!this.session) return + + const result = await capturePane(this.session.name) + if (!result) return + + if (!hasChanged(result.lines)) return + + const frame = parseAnsiFrame(result.lines, result.width, result.height) + this.lastFrame = frame + this.renderFrameToBuffer(frame) + } + + private renderFrameToBuffer(frame: ParsedFrame) { + const fb = this.frameBuffer + if (!fb) return + + const w = Math.min(frame.width, fb.width) + const h = Math.min(frame.height, fb.height) + + for (let y = 0; y < h; y++) { + const row = frame.cells[y] + if (!row) continue + for (let x = 0; x < w; x++) { + const cell = row[x] + if (!cell) continue + fb.setCell(x, y, cell.char, cell.fg, cell.bg, cell.attrs) + } + } + } + + protected renderSelf(buffer: OptimizedBuffer) { + if (this.lastFrame) { + this.renderFrameToBuffer(this.lastFrame) + } + super.renderSelf(buffer) + } + + protected onResize(width: number, height: number) { + super.onResize(width, height) + 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) + }) + } + } + + protected destroySelf() { + this.detach() + super.destroySelf() + } +} diff --git a/src/index.ts b/src/index.ts index 8b2987c..584a872 100755 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,9 @@ import { generateMockProjects, generateMockSessions, generateMockBranches, gener 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" @@ -51,6 +54,15 @@ 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 @@ -671,6 +683,14 @@ async function handleKeypress(key: KeyEvent) { renderer.destroy() return + case "t": + // Switch to grid view if there are tmux sessions + if (sessionGrid && sessionGrid.paneCount > 0) { + switchToGrid() + return + } + return + default: return } @@ -714,9 +734,7 @@ async function expandProject(projectIndex: number) { async function doLaunch() { if (selectedProjects.size === 0 && selectedSessions.size === 0) return - const total = selectedProjects.size + selectedSessions.size if (demoMode) { - // Just clear selections in demo mode selectedProjects.clear() selectedSessions.clear() selectedBranches.clear() @@ -724,12 +742,188 @@ async function doLaunch() { updateAll() return } - await launchSelections(projects, selectedProjects, selectedSessions, selectedBranches) + + // 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() - rebuildDisplayRows() + 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 ─────────────────────────────────────────────────────────── @@ -767,7 +961,7 @@ async function main() { }) // Build layout - const mainBox = new BoxRenderable(renderer, { + mainBox = new BoxRenderable(renderer, { flexDirection: "column", width: "100%", height: "100%", @@ -861,6 +1055,14 @@ async function main() { updateUsagePanel() }).catch(() => {}) + // 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 + }) + renderer.keyInput.on("keypress", handleKeypress) // Live session monitoring @@ -933,6 +1135,18 @@ async function main() { bottomPanelMode = "idle" } if (changed) updateAll() + + // Update grid pane statuses (flash idle sessions) + if (sessionGrid && 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) + } + } } }, 5000) } diff --git a/src/tmux/ansi-parser.ts b/src/tmux/ansi-parser.ts new file mode 100644 index 0000000..399a522 --- /dev/null +++ b/src/tmux/ansi-parser.ts @@ -0,0 +1,188 @@ +import { RGBA, TextAttributes } from "@opentui/core" + +export interface TermCell { + char: string + fg: RGBA + bg: RGBA + attrs: number +} + +export interface ParsedFrame { + cells: TermCell[][] + width: number + height: number +} + +// Standard 16 ANSI colors (Tokyo Night palette) +const ANSI_16: [number, number, number][] = [ + [0x1a, 0x1b, 0x26], // 0 black + [0xf7, 0x76, 0x8e], // 1 red + [0x9e, 0xce, 0x6a], // 2 green + [0xe0, 0xaf, 0x68], // 3 yellow + [0x7a, 0xa2, 0xf7], // 4 blue + [0xbb, 0x9a, 0xf7], // 5 magenta + [0x7d, 0xcf, 0xff], // 6 cyan + [0xa9, 0xb1, 0xd6], // 7 white + [0x56, 0x5f, 0x89], // 8 bright black + [0xf7, 0x76, 0x8e], // 9 bright red + [0x9e, 0xce, 0x6a], // 10 bright green + [0xe0, 0xaf, 0x68], // 11 bright yellow + [0x7a, 0xa2, 0xf7], // 12 bright blue + [0xbb, 0x9a, 0xf7], // 13 bright magenta + [0x7d, 0xcf, 0xff], // 14 bright cyan + [0xc0, 0xca, 0xf5], // 15 bright white +] + +const DEFAULT_FG = RGBA.fromInts(0xc0, 0xca, 0xf5, 255) +const DEFAULT_BG = RGBA.fromInts(0x1a, 0x1b, 0x26, 255) + +function color256(n: number): RGBA { + if (n < 16) { + const [r, g, b] = ANSI_16[n] + return RGBA.fromInts(r, g, b, 255) + } + if (n < 232) { + const idx = n - 16 + const r = Math.floor(idx / 36) * 51 + const g = Math.floor((idx % 36) / 6) * 51 + const b = (idx % 6) * 51 + return RGBA.fromInts(r, g, b, 255) + } + const v = 8 + (n - 232) * 10 + return RGBA.fromInts(v, v, v, 255) +} + +export function parseAnsiFrame(lines: string[], width: number, height: number): ParsedFrame { + const cells: TermCell[][] = [] + + for (let row = 0; row < height; row++) { + const line = row < lines.length ? lines[row] : "" + const rowCells = parseLine(line, width) + cells.push(rowCells) + } + + return { cells, width, height } +} + +function parseLine(line: string, width: number): TermCell[] { + const cells: TermCell[] = [] + let currentFg = DEFAULT_FG + let currentBg = DEFAULT_BG + let currentAttrs = TextAttributes.NONE + let i = 0 + let col = 0 + + while (i < line.length && col < width) { + if (line[i] === "\x1b" && i + 1 < line.length && line[i + 1] === "[") { + // Parse CSI sequence + i += 2 + const params: number[] = [] + let num = "" + + while (i < line.length) { + const ch = line[i] + if (ch >= "0" && ch <= "9") { + num += ch + i++ + } else if (ch === ";") { + params.push(num === "" ? 0 : parseInt(num, 10)) + num = "" + i++ + } else { + params.push(num === "" ? 0 : parseInt(num, 10)) + i++ + if (ch === "m") { + applyParams(params) + } + // Ignore other CSI sequences (cursor movement, etc.) + break + } + } + continue + } + + cells.push({ char: line[i], fg: currentFg, bg: currentBg, attrs: currentAttrs }) + col++ + i++ + } + + // Pad remaining columns + while (col < width) { + cells.push({ char: " ", fg: DEFAULT_FG, bg: DEFAULT_BG, attrs: TextAttributes.NONE }) + col++ + } + + return cells + + function applyParams(params: number[]) { + let j = 0 + while (j < params.length) { + const p = params[j] + switch (p) { + case 0: + currentFg = DEFAULT_FG + currentBg = DEFAULT_BG + currentAttrs = TextAttributes.NONE + break + case 1: currentAttrs |= TextAttributes.BOLD; break + case 2: currentAttrs |= TextAttributes.DIM; break + case 3: currentAttrs |= TextAttributes.ITALIC; break + case 4: currentAttrs |= TextAttributes.UNDERLINE; break + case 5: currentAttrs |= TextAttributes.BLINK; break + case 7: currentAttrs |= TextAttributes.INVERSE; break + case 8: currentAttrs |= TextAttributes.HIDDEN; break + case 9: currentAttrs |= TextAttributes.STRIKETHROUGH; break + case 22: currentAttrs &= ~(TextAttributes.BOLD | TextAttributes.DIM); break + case 23: currentAttrs &= ~TextAttributes.ITALIC; break + case 24: currentAttrs &= ~TextAttributes.UNDERLINE; break + case 27: currentAttrs &= ~TextAttributes.INVERSE; break + case 29: currentAttrs &= ~TextAttributes.STRIKETHROUGH; break + // Foreground + case 30: case 31: case 32: case 33: case 34: case 35: case 36: case 37: { + const [r, g, b] = ANSI_16[p - 30] + currentFg = RGBA.fromInts(r, g, b, 255) + break + } + case 38: + if (params[j + 1] === 5 && j + 2 < params.length) { + currentFg = color256(params[j + 2]) + j += 2 + } else if (params[j + 1] === 2 && j + 4 < params.length) { + currentFg = RGBA.fromInts(params[j + 2], params[j + 3], params[j + 4], 255) + j += 4 + } + break + case 39: currentFg = DEFAULT_FG; break + // Background + case 40: case 41: case 42: case 43: case 44: case 45: case 46: case 47: { + const [r, g, b] = ANSI_16[p - 40] + currentBg = RGBA.fromInts(r, g, b, 255) + break + } + case 48: + if (params[j + 1] === 5 && j + 2 < params.length) { + currentBg = color256(params[j + 2]) + j += 2 + } else if (params[j + 1] === 2 && j + 4 < params.length) { + currentBg = RGBA.fromInts(params[j + 2], params[j + 3], params[j + 4], 255) + j += 4 + } + break + case 49: currentBg = DEFAULT_BG; break + // Bright foreground + case 90: case 91: case 92: case 93: case 94: case 95: case 96: case 97: { + const [r, g, b] = ANSI_16[p - 90 + 8] + currentFg = RGBA.fromInts(r, g, b, 255) + break + } + // Bright background + case 100: case 101: case 102: case 103: case 104: case 105: case 106: case 107: { + const [r, g, b] = ANSI_16[p - 100 + 8] + currentBg = RGBA.fromInts(r, g, b, 255) + break + } + } + j++ + } + } +} diff --git a/src/tmux/capture.ts b/src/tmux/capture.ts new file mode 100644 index 0000000..9134031 --- /dev/null +++ b/src/tmux/capture.ts @@ -0,0 +1,53 @@ +export interface CaptureResult { + lines: string[] + cursorX: number + cursorY: number + width: number + 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 [contentText, infoText] = await Promise.all([ + new Response(contentProc.stdout).text(), + new Response(infoProc.stdout).text(), + ]) + + const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited]) + if (codeContent !== 0 || codeInfo !== 0) return null + + const parts = infoText.trim().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 } + } catch { + return null + } +} + +// Hash for diffing - skip re-render if nothing changed +let lastHash = "" + +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 + return true +} + +export function resetHash() { + lastHash = "" +} diff --git a/src/tmux/input-bridge.ts b/src/tmux/input-bridge.ts new file mode 100644 index 0000000..01d912f --- /dev/null +++ b/src/tmux/input-bridge.ts @@ -0,0 +1,71 @@ +// Forwards raw terminal input sequences to a tmux session + +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 + const special = mapSpecialSequence(rawSequence) + + if (special) { + const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, special], { + stdout: "ignore", stderr: "ignore", + }) + await proc.exited + } 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 + } +} + +// 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", + "\t": "Tab", + "\x1b": "Escape", + "\x7f": "BSpace", + "\x1b[A": "Up", + "\x1b[B": "Down", + "\x1b[C": "Right", + "\x1b[D": "Left", + "\x1b[H": "Home", + "\x1b[F": "End", + "\x1b[3~": "DC", // Delete + "\x1b[5~": "PageUp", + "\x1b[6~": "PageDown", + "\x1b[2~": "IC", // Insert + "\x1bOP": "F1", + "\x1bOQ": "F2", + "\x1bOR": "F3", + "\x1bOS": "F4", + "\x1b[15~": "F5", + "\x1b[17~": "F6", + "\x1b[18~": "F7", + "\x1b[19~": "F8", + "\x1b[20~": "F9", + "\x1b[21~": "F10", + "\x1b[23~": "F11", + "\x1b[24~": "F12", + "\x1b[Z": "BTab", // Shift-Tab + } + + if (MAP[seq]) return MAP[seq] + + // Ctrl+letter: 0x01-0x1a → C-a through C-z + if (seq.length === 1) { + const code = seq.charCodeAt(0) + if (code >= 1 && code <= 26) { + return "C-" + String.fromCharCode(code + 96) + } + } + + return null +} diff --git a/src/tmux/session-manager.ts b/src/tmux/session-manager.ts new file mode 100644 index 0000000..ad39888 --- /dev/null +++ b/src/tmux/session-manager.ts @@ -0,0 +1,120 @@ +export interface TmuxSession { + name: string + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string + alive: boolean + width: number + height: number + colorIndex: number +} + +const sessions = new Map() +let colorCounter = 0 + +export function getSessions(): Map { + return sessions +} + +export function getSessionByProject(projectPath: string): TmuxSession[] { + 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([ + "tmux", "new-session", "-d", + "-s", name, + "-x", String(opts.width), + "-y", String(opts.height), + cmd, + ], { stdout: "ignore", stderr: "pipe" }) + await proc.exited + + // 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: TmuxSession = { + name, + projectPath: opts.projectPath, + projectName: opts.projectName, + sessionId: opts.sessionId, + targetBranch: opts.targetBranch, + alive: true, + width: opts.width, + height: opts.height, + colorIndex: ci, + } + + sessions.set(name, session) + return session +} + +export async function killSession(name: string): Promise { + const proc = Bun.spawn(["tmux", "kill-session", "-t", name], { + stdout: "ignore", stderr: "ignore", + }) + await proc.exited + sessions.delete(name) +} + +export async function resizePane(name: string, width: number, height: number): Promise { + const s = sessions.get(name) + if (s) { + s.width = width + s.height = height + } + const proc = Bun.spawn([ + "tmux", "resize-window", "-t", name, "-x", String(width), "-y", String(height), + ], { stdout: "ignore", stderr: "ignore" }) + await proc.exited +} + +export async function isAlive(name: string): Promise { + const proc = Bun.spawn(["tmux", "has-session", "-t", name], { + stdout: "ignore", stderr: "ignore", + }) + const code = await proc.exited + const alive = code === 0 + const s = sessions.get(name) + if (s) s.alive = alive + return alive +} + +export async function refreshAlive(): Promise { + const checks = [...sessions.keys()].map(async name => { + const alive = await isAlive(name) + if (!alive) sessions.delete(name) + }) + await Promise.all(checks) +} + +export async function cleanupAll(): Promise { + const kills = [...sessions.keys()].map(name => killSession(name)) + await Promise.all(kills) +} + +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}` +}