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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user