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:
Alejandro Gutiérrez
2026-02-25 00:37:35 +00:00
parent 9c50399e57
commit 4132744a01
7 changed files with 1041 additions and 5 deletions

53
src/tmux/capture.ts Normal file
View 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 = ""
}