feat: add tmux-based multi-terminal grid view
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 <noreply@anthropic.com>
This commit is contained in:
253
src/components/session-grid.ts
Normal file
253
src/components/session-grid.ts
Normal file
@@ -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<string, ReturnType<typeof setInterval>>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/components/terminal-view.ts
Normal file
137
src/components/terminal-view.ts
Normal file
@@ -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<typeof setInterval> | 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
224
src/index.ts
224
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 { 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 { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage"
|
||||||
import { launchSelections } from "./actions/launcher"
|
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 type { Project, DisplayRow } from "./lib/types"
|
||||||
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
||||||
|
|
||||||
@@ -51,6 +54,15 @@ let destroyed = false
|
|||||||
let idleCursor = 0
|
let idleCursor = 0
|
||||||
let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = []
|
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 ────────────────────────────────────────────────────────
|
// ─── UI Refs ────────────────────────────────────────────────────────
|
||||||
let renderer: CliRenderer
|
let renderer: CliRenderer
|
||||||
let headerText: TextRenderable
|
let headerText: TextRenderable
|
||||||
@@ -671,6 +683,14 @@ async function handleKeypress(key: KeyEvent) {
|
|||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
case "t":
|
||||||
|
// Switch to grid view if there are tmux sessions
|
||||||
|
if (sessionGrid && sessionGrid.paneCount > 0) {
|
||||||
|
switchToGrid()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -714,9 +734,7 @@ async function expandProject(projectIndex: number) {
|
|||||||
|
|
||||||
async function doLaunch() {
|
async function doLaunch() {
|
||||||
if (selectedProjects.size === 0 && selectedSessions.size === 0) return
|
if (selectedProjects.size === 0 && selectedSessions.size === 0) return
|
||||||
const total = selectedProjects.size + selectedSessions.size
|
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
// Just clear selections in demo mode
|
|
||||||
selectedProjects.clear()
|
selectedProjects.clear()
|
||||||
selectedSessions.clear()
|
selectedSessions.clear()
|
||||||
selectedBranches.clear()
|
selectedBranches.clear()
|
||||||
@@ -724,12 +742,188 @@ async function doLaunch() {
|
|||||||
updateAll()
|
updateAll()
|
||||||
return
|
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()
|
selectedProjects.clear()
|
||||||
selectedSessions.clear()
|
selectedSessions.clear()
|
||||||
selectedBranches.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()
|
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<boolean> {
|
||||||
|
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 ───────────────────────────────────────────────────────────
|
// ─── Main ───────────────────────────────────────────────────────────
|
||||||
@@ -767,7 +961,7 @@ async function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Build layout
|
// Build layout
|
||||||
const mainBox = new BoxRenderable(renderer, {
|
mainBox = new BoxRenderable(renderer, {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -861,6 +1055,14 @@ async function main() {
|
|||||||
updateUsagePanel()
|
updateUsagePanel()
|
||||||
}).catch(() => {})
|
}).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)
|
renderer.keyInput.on("keypress", handleKeypress)
|
||||||
|
|
||||||
// Live session monitoring
|
// Live session monitoring
|
||||||
@@ -933,6 +1135,18 @@ async function main() {
|
|||||||
bottomPanelMode = "idle"
|
bottomPanelMode = "idle"
|
||||||
}
|
}
|
||||||
if (changed) updateAll()
|
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)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|||||||
188
src/tmux/ansi-parser.ts
Normal file
188
src/tmux/ansi-parser.ts
Normal file
@@ -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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/tmux/capture.ts
Normal file
53
src/tmux/capture.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export interface CaptureResult {
|
||||||
|
lines: string[]
|
||||||
|
cursorX: number
|
||||||
|
cursorY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function capturePane(sessionName: string): Promise<CaptureResult | null> {
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
71
src/tmux/input-bridge.ts
Normal file
71
src/tmux/input-bridge.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Forwards raw terminal input sequences to a tmux session
|
||||||
|
|
||||||
|
export async function sendKeys(sessionName: string, rawSequence: string): Promise<void> {
|
||||||
|
// 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<string, string> = {
|
||||||
|
"\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
|
||||||
|
}
|
||||||
120
src/tmux/session-manager.ts
Normal file
120
src/tmux/session-manager.ts
Normal file
@@ -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<string, TmuxSession>()
|
||||||
|
let colorCounter = 0
|
||||||
|
|
||||||
|
export function getSessions(): Map<string, TmuxSession> {
|
||||||
|
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<TmuxSession> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user