Files

120 lines
3.4 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 function closeSurface(surface: string): void {
requireTmux();
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
rebalanceSurfaces();
}