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:
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