139 lines
3.8 KiB
TypeScript
139 lines
3.8 KiB
TypeScript
/**
|
|
* tmux surface backend.
|
|
*
|
|
* This module only implements tmux-specific terminal operations. Backend
|
|
* selection, command scripts, and exit polling live in surface.ts.
|
|
*/
|
|
import { execFile, execFileSync } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const commandAvailability = new Map<string, boolean>();
|
|
|
|
function hasCommand(command: string): boolean {
|
|
const cached = commandAvailability.get(command);
|
|
if (cached !== undefined) return cached;
|
|
|
|
let available = false;
|
|
try {
|
|
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
|
|
available = true;
|
|
} catch {
|
|
available = false;
|
|
}
|
|
|
|
commandAvailability.set(command, available);
|
|
return available;
|
|
}
|
|
|
|
export function isAvailable(): boolean {
|
|
return !!process.env.TMUX && hasCommand("tmux");
|
|
}
|
|
|
|
export function setupHint(): string {
|
|
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
|
|
}
|
|
|
|
function requireTmux(): void {
|
|
if (!isAvailable()) {
|
|
throw new Error(`tmux is required for this surface. ${setupHint()}`);
|
|
}
|
|
}
|
|
|
|
/** tmux layout used to keep sibling panes evenly sized. */
|
|
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
|
|
let rebalanceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function rebalanceSurfaces(hintPane?: string): void {
|
|
const target = process.env.TMUX_PANE ?? hintPane;
|
|
if (!target) return;
|
|
if (rebalanceTimer) clearTimeout(rebalanceTimer);
|
|
rebalanceTimer = setTimeout(() => {
|
|
rebalanceTimer = null;
|
|
try {
|
|
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
|
|
encoding: "utf8",
|
|
});
|
|
} catch {
|
|
// Pane/window may be gone; balancing is best-effort.
|
|
}
|
|
}, 120);
|
|
}
|
|
|
|
export function createSurface(name: string): string {
|
|
return createSurfaceSplit(name, "right", process.env.TMUX_PANE);
|
|
}
|
|
|
|
export function createSurfaceSplit(
|
|
name: string,
|
|
direction: "left" | "right" | "up" | "down",
|
|
fromSurface?: string,
|
|
): string {
|
|
void name;
|
|
requireTmux();
|
|
|
|
const args = ["split-window", "-d"];
|
|
args.push(direction === "left" || direction === "right" ? "-h" : "-v");
|
|
if (direction === "left" || direction === "up") args.push("-b");
|
|
if (fromSurface) args.push("-t", fromSurface);
|
|
args.push("-P", "-F", "#{pane_id}");
|
|
|
|
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
|
|
if (!pane.startsWith("%")) {
|
|
throw new Error(`Unexpected tmux split-window output: ${pane}`);
|
|
}
|
|
|
|
rebalanceSurfaces(pane);
|
|
return pane;
|
|
}
|
|
|
|
export function sendCommand(surface: string, command: string): void {
|
|
requireTmux();
|
|
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
|
|
execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
|
|
}
|
|
|
|
export function readScreen(surface: string, lines = 50): string {
|
|
requireTmux();
|
|
return execFileSync(
|
|
"tmux",
|
|
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
}
|
|
|
|
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
|
requireTmux();
|
|
const { stdout } = await execFileAsync(
|
|
"tmux",
|
|
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return stdout;
|
|
}
|
|
|
|
export async function isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
|
|
requireTmux();
|
|
try {
|
|
await execFileAsync("tmux", ["display-message", "-p", "-t", surface, "#{pane_id}"], {
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
killSignal: "SIGKILL",
|
|
signal,
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function closeSurface(surface: string): void {
|
|
requireTmux();
|
|
try {
|
|
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
|
} catch {
|
|
// Replaced-shell launches may close their own pane before cleanup.
|
|
}
|
|
rebalanceSurfaces();
|
|
}
|