feat: add direct PTY grid with expand/select mode for text copying

Replace tmux-based grid with direct PTY rendering. Add [MAX] button
to expand a pane to fullscreen and [SEL] to enable native text
selection within the expanded pane. Esc exits select/expanded mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 01:41:31 +00:00
parent 4132744a01
commit 3ce6572952
13 changed files with 2374 additions and 194 deletions

View File

@@ -1,3 +1,9 @@
// Captures tmux pane content via persistent streaming subprocesses.
// Push-based: notifies subscribers immediately when new frames arrive.
// Zero polling overhead on the JS side — callbacks fire on stream data.
import type { Subprocess } from "bun"
export interface CaptureResult {
lines: string[]
cursorX: number
@@ -6,29 +12,157 @@ export interface CaptureResult {
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 SEP = "%%CLADM_FRAME%%"
const [contentText, infoText] = await Promise.all([
new Response(contentProc.stdout).text(),
new Response(infoProc.stdout).text(),
])
type FrameCallback = (frame: CaptureResult) => void
const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited])
if (codeContent !== 0 || codeInfo !== 0) return null
interface PaneCapture {
proc: Subprocess<"ignore", "pipe", "ignore">
latest: CaptureResult | null
buf: string
callbacks: Set<FrameCallback>
}
const parts = infoText.trim().split(" ")
const panes = new Map<string, PaneCapture>()
export function startCapture(sessionName: string, intervalMs = 100): void {
if (panes.has(sessionName)) return
const script = `while true; do
tmux capture-pane -t '${sessionName}' -p -e 2>/dev/null
echo '${SEP}'
tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}' 2>/dev/null
echo '${SEP}END'
sleep ${(intervalMs / 1000).toFixed(3)}
done`
const proc = Bun.spawn(["sh", "-c", script], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
})
const state: PaneCapture = { proc, latest: null, buf: "", callbacks: new Set() }
panes.set(sessionName, state)
const reader = proc.stdout.getReader()
const decoder = new TextDecoder()
;(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
state.buf += decoder.decode(value, { stream: true })
processBuffer(sessionName, state)
}
} catch {
// Process died
}
})()
}
function processBuffer(sessionName: string, state: PaneCapture) {
while (true) {
const endMarker = SEP + "END"
const endIdx = state.buf.indexOf(endMarker)
if (endIdx < 0) break
const frame = state.buf.slice(0, endIdx)
state.buf = state.buf.slice(endIdx + endMarker.length)
if (state.buf.startsWith("\n")) state.buf = state.buf.slice(1)
const sepIdx = frame.indexOf(SEP)
if (sepIdx < 0) continue
const contentText = frame.slice(0, sepIdx)
const infoText = frame.slice(sepIdx + SEP.length).trim()
const parts = infoText.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")
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
state.latest = { lines, cursorX, cursorY, width, height }
// Push to all subscribers immediately
for (const cb of state.callbacks) {
try { cb(state.latest) } catch {}
}
}
}
// Subscribe to frame updates. Returns unsubscribe function.
export function onFrame(sessionName: string, cb: FrameCallback): () => void {
const state = panes.get(sessionName)
if (state) state.callbacks.add(cb)
// Unsub looks up current state (handles setCaptureRate restarts)
return () => {
const s = panes.get(sessionName)
if (s) s.callbacks.delete(cb)
}
}
export function getLatestFrame(sessionName: string): CaptureResult | null {
return panes.get(sessionName)?.latest ?? null
}
export function stopCapture(sessionName: string): void {
const state = panes.get(sessionName)
if (!state) return
if (!state.proc.killed) state.proc.kill()
state.callbacks.clear()
panes.delete(sessionName)
}
// Change capture rate without losing subscribers
export function setCaptureRate(sessionName: string, intervalMs: number): void {
const state = panes.get(sessionName)
if (!state) return
const savedCallbacks = new Set(state.callbacks)
stopCapture(sessionName)
startCapture(sessionName, intervalMs)
const newState = panes.get(sessionName)
if (newState) {
for (const cb of savedCallbacks) newState.callbacks.add(cb)
}
}
export function stopAllCaptures(): void {
for (const [name] of panes) stopCapture(name)
}
// Legacy one-shot capture (for non-grid use)
export async function capturePane(sessionName: string): Promise<CaptureResult | null> {
const latest = getLatestFrame(sessionName)
if (latest) return latest
try {
const proc = Bun.spawn(["sh", "-c",
`tmux capture-pane -t '${sessionName}' -p -e && echo '${SEP}' && tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`
], { stdout: "pipe", stderr: "ignore" })
const text = await new Response(proc.stdout).text()
const code = await proc.exited
if (code !== 0) return null
const sepIdx = text.lastIndexOf(SEP)
if (sepIdx < 0) return null
const contentText = text.slice(0, sepIdx)
const infoText = text.slice(sepIdx + SEP.length).trim()
const parts = infoText.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 }
@@ -37,17 +171,24 @@ export async function capturePane(sessionName: string): Promise<CaptureResult |
}
}
// Hash for diffing - skip re-render if nothing changed
let lastHash = ""
// Hash for diffing skip re-render if nothing changed
const lastHashes = new Map<string, number>()
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
export function hasChanged(lines: string[], key = "_default"): boolean {
let h = 5381
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
for (let j = 0; j < line.length; j++) {
h = ((h << 5) + h + line.charCodeAt(j)) | 0
}
h = ((h << 5) + h + 10) | 0
}
const prev = lastHashes.get(key)
if (prev === h) return false
lastHashes.set(key, h)
return true
}
export function resetHash() {
lastHash = ""
export function resetHash(key = "_default") {
lastHashes.delete(key)
}

View File

@@ -1,31 +1,77 @@
// Forwards raw terminal input sequences to a tmux session
// Forwards raw terminal input to tmux sessions via a persistent shell.
// Zero process-spawn overhead per keystroke — writes to stdin of a long-lived sh.
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
import type { Subprocess } from "bun"
let shell: Subprocess<"ignore", "pipe", "ignore"> | null = null
function getShell() {
if (shell && !shell.killed) return shell
shell = Bun.spawn(["sh"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
return shell
}
export function sendKeys(sessionName: string, rawSequence: string): void {
const sh = getShell()
const special = mapSpecialSequence(rawSequence)
let cmd: string
if (special) {
const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, special], {
stdout: "ignore", stderr: "ignore",
})
await proc.exited
cmd = `tmux send-keys -t '${sessionName}' ${special}\n`
} 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
const hex = [...rawSequence].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n`
}
sh.stdin.write(cmd)
}
// Forward mouse events to tmux as SGR escape sequences
export function sendMouseEvent(sessionName: string, x: number, y: number, btn: number, release: boolean): void {
const sh = getShell()
const end = release ? "m" : "M"
const seq = `\x1b[<${btn};${x};${y}${end}`
const hex = [...seq].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
const cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n`
sh.stdin.write(cmd)
}
// Scroll a tmux pane using copy-mode (works with any application in the pane)
export function sendScroll(sessionName: string, direction: "up" | "down", lines = 3): void {
const sh = getShell()
if (direction === "up") {
// Enter copy mode (no-op if already in it) then scroll up
sh.stdin.write(`tmux copy-mode -t '${sessionName}' 2>/dev/null; tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-up 2>/dev/null\n`)
} else {
// Scroll down in copy mode; if we hit bottom, exit copy mode
sh.stdin.write(`tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-down 2>/dev/null\n`)
}
}
// Exit copy mode (e.g., when user starts typing)
export function exitCopyMode(sessionName: string): void {
const sh = getShell()
sh.stdin.write(`tmux send-keys -t '${sessionName}' -X cancel 2>/dev/null\n`)
}
export function cleanupInputQueue(_sessionName: string) {
// No per-session cleanup needed with shared shell
}
export function destroyShell() {
if (shell && !shell.killed) {
shell.stdin.end()
shell.kill()
}
shell = null
}
// 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",

View File

@@ -63,6 +63,12 @@ export async function createSession(opts: {
}
sessions.set(name, session)
// Enable mouse mode so clicks/scrolls forward to the application
Bun.spawn(["tmux", "set", "-t", name, "mouse", "on"], {
stdout: "ignore", stderr: "ignore",
})
return session
}