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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user