/** * backends/zellij.ts — Zellij 终端后端 * * 包含 Zellij 特定的 surface 创建(放置规划:tiled/stacked/tab)、命令发送、 * 屏幕读写、关闭与重命名。ZellijPlacementPlan 等公开类型与函数从此文件导出。 */ import { execFileSync, execFile } from "node:child_process"; import { promisify } from "node:util"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { tailLines, sleepSync, envPositiveInteger } from "../shell.ts"; import { withFileLock } from "./shared.ts"; import type { BackendOps } from "./types.ts"; const execFileAsync = promisify(execFile); // ── Zellij 放置规划常量 ── /** Mirrors Zellij 0.44.x tab minimums */ const ZELLIJ_MIN_TERMINAL_WIDTH = 5; const ZELLIJ_MIN_TERMINAL_HEIGHT = 5; const ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO = 4; /** Pi subagent 可用空间最小值(可通过环境变量调优) */ const DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS = 50; const DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS = 10; // ── Zellij 公开类型 ── export interface ZellijPaneSnapshot { id: number; is_plugin?: boolean; is_floating?: boolean; is_selectable?: boolean; exited?: boolean; pane_rows?: number; pane_columns?: number; tab_id?: number; is_focused?: boolean; } export type ZellijSplitDirection = "down" | "right"; export type ZellijPlacementPlan = | { mode: "split"; anchorPaneId: number; targetPaneId: number; tabId: number; splitDirection: ZellijSplitDirection; } | { mode: "stack"; anchorPaneId: number; targetPaneId: number; tabId: number }; // ── Zellij 内部辅助 ── /** 需要指定 --pane-id 的 Zellij pane scoped actions */ const ZELLIJ_PANE_SCOPED_ACTIONS = new Set([ "close-pane", "dump-screen", "rename-pane", "move-pane", "write", "write-chars", "send-keys", ]); /** 从 surface 字符串提取 pane id(去掉 "pane:" 前缀) */ function zellijPaneId(surface: string): string { return surface.startsWith("pane:") ? surface.slice("pane:".length) : surface; } /** 构造 Zellij 子进程环境变量 */ function zellijEnv(surface?: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; if (surface) { env.ZELLIJ_PANE_ID = zellijPaneId(surface); } return env; } /** 构造 Zellij action 参数列表,必要时追加 --pane-id */ function zellijActionArgs(args: string[], surface?: string): string[] { if (!surface) return ["action", ...args]; const action = args[0]; if (!ZELLIJ_PANE_SCOPED_ACTIONS.has(action)) return ["action", ...args]; // Don't double-add if caller already specified it. if (args.includes("--pane-id") || args.includes("-p")) return ["action", ...args]; return ["action", action, "--pane-id", zellijPaneId(surface), ...args.slice(1)]; } /** 同步执行 Zellij action */ function zellijActionSync(args: string[], surface?: string): string { return execFileSync("zellij", zellijActionArgs(args, surface), { encoding: "utf8", env: zellijEnv(surface), }); } /** 异步执行 Zellij action */ async function zellijActionAsync(args: string[], surface?: string): Promise { const { stdout } = await execFileAsync("zellij", zellijActionArgs(args, surface), { encoding: "utf8", env: zellijEnv(surface), }); return stdout; } // ── Zellij 放置规划公开函数 ── function paneArea(pane: ZellijPaneSnapshot): number { return (pane.pane_rows ?? 0) * (pane.pane_columns ?? 0); } function isUsableZellijTiledPane(pane: ZellijPaneSnapshot): boolean { return ( !pane.is_plugin && !pane.is_floating && pane.is_selectable !== false && !pane.exited && typeof pane.pane_rows === "number" && typeof pane.pane_columns === "number" ); } export function predictZellijSplitDirection(pane: ZellijPaneSnapshot): ZellijSplitDirection | null { const columns = pane.pane_columns ?? 0; const rows = pane.pane_rows ?? 0; if (columns < ZELLIJ_MIN_TERMINAL_WIDTH || rows < ZELLIJ_MIN_TERMINAL_HEIGHT) return null; if ( rows * ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO > columns && rows > ZELLIJ_MIN_TERMINAL_HEIGHT * 2 ) { return "down"; } if (columns > ZELLIJ_MIN_TERMINAL_WIDTH * 2) { return "right"; } return null; } export function canSplitZellijPane( pane: ZellijPaneSnapshot, minColumns = ZELLIJ_MIN_TERMINAL_WIDTH, minRows = ZELLIJ_MIN_TERMINAL_HEIGHT, ): boolean { const columns = pane.pane_columns ?? 0; const rows = pane.pane_rows ?? 0; const direction = predictZellijSplitDirection(pane); if (!direction) return false; if (direction === "down") { return columns >= minColumns && Math.floor(rows / 2) >= minRows; } return rows >= minRows && Math.floor(columns / 2) >= minColumns; } function zellijTabPanesForParent( panes: ZellijPaneSnapshot[], parentPaneId: number, ): { parentPane: ZellijPaneSnapshot; tabPanes: ZellijPaneSnapshot[] } | null { const parentPane = panes.find((pane) => !pane.is_plugin && pane.id === parentPaneId); if (!parentPane || typeof parentPane.tab_id !== "number") return null; const tabPanes = panes .filter((pane) => pane.tab_id === parentPane.tab_id) .filter(isUsableZellijTiledPane); return { parentPane, tabPanes }; } export function selectZellijStackPlacement( panes: ZellijPaneSnapshot[], parentPaneId: number, ): ZellijPlacementPlan | null { const tabInfo = zellijTabPanesForParent(panes, parentPaneId); if (!tabInfo) return null; const stackTarget = tabInfo.tabPanes .filter((pane) => pane.id !== parentPaneId) .sort((a, b) => paneArea(b) - paneArea(a))[0]; if (!stackTarget) return null; return { mode: "stack", anchorPaneId: stackTarget.id, targetPaneId: stackTarget.id, tabId: tabInfo.parentPane.tab_id!, }; } export function selectZellijPlacement( panes: ZellijPaneSnapshot[], parentPaneId: number, minColumns = DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS, minRows = DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS, ): ZellijPlacementPlan | null { const tabInfo = zellijTabPanesForParent(panes, parentPaneId); if (!tabInfo) return null; const zellijSplitCandidates = tabInfo.tabPanes .map((pane) => ({ pane, splitDirection: predictZellijSplitDirection(pane) })) .filter( (candidate): candidate is { pane: ZellijPaneSnapshot; splitDirection: ZellijSplitDirection } => candidate.splitDirection !== null && canSplitZellijPane(candidate.pane, ZELLIJ_MIN_TERMINAL_WIDTH, ZELLIJ_MIN_TERMINAL_HEIGHT), ); const safeSplitCandidates = zellijSplitCandidates.filter((candidate) => canSplitZellijPane(candidate.pane, minColumns, minRows), ); // Split creation is tab-scoped, so Zellij chooses the concrete split pane. // Only split when every pane Zellij might split would remain usable. if ( zellijSplitCandidates.length > 0 && safeSplitCandidates.length === zellijSplitCandidates.length ) { const splitTarget = safeSplitCandidates.sort((a, b) => paneArea(b.pane) - paneArea(a.pane))[0]; return { mode: "split", anchorPaneId: splitTarget.pane.id, targetPaneId: splitTarget.pane.id, tabId: tabInfo.parentPane.tab_id!, splitDirection: splitTarget.splitDirection, }; } return selectZellijStackPlacement(panes, parentPaneId); } // ── Zellij pane 读取 ── function parseZellijPaneSurface(rawId: string, context: string): string { const idMatch = rawId.match(/(\d+)/); if (!idMatch) { throw new Error(`Unexpected zellij pane id from ${context}: ${rawId || "(empty)"}`); } return `pane:${idMatch[1]}`; } /** 带重试(最多 3 次)读取 Zellij pane 列表 */ function readZellijPanes(): ZellijPaneSnapshot[] { const maxAttempts = 3; let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const output = zellijActionSync(["list-panes", "--json", "--geometry", "--state", "--tab"]); if (!output.trim()) { throw new Error("Unexpected zellij list-panes output: empty"); } const parsed = JSON.parse(output); if (!Array.isArray(parsed)) { throw new Error("Unexpected zellij list-panes output: not an array"); } return parsed as ZellijPaneSnapshot[]; } catch (error) { lastError = error; if (attempt < maxAttempts - 1) sleepSync(50); } } throw lastError; } // ── Zellij surface 创建 ── function createZellijTiledPane(name: string, tabId: number): string { const args = ["new-pane", "--tab-id", String(tabId), "--name", name, "--cwd", process.cwd()]; return parseZellijPaneSurface(zellijActionSync(args).trim(), "new-pane"); } function createZellijStackedPane(name: string, anchorSurface: string): string { const args = [ "new-pane", "--stacked", "--near-current-pane", "--name", name, "--cwd", process.cwd(), ]; return parseZellijPaneSurface(zellijActionSync(args, anchorSurface).trim(), "new-pane --stacked"); } function createZellijTab(name: string): string { const tabIdRaw = zellijActionSync(["new-tab", "--name", name, "--cwd", process.cwd()]).trim(); const tabId = Number(tabIdRaw); if (!Number.isInteger(tabId)) { throw new Error(`Unexpected zellij tab id from new-tab: ${tabIdRaw || "(empty)"}`); } try { const panes = readZellijPanes(); const pane = panes.find( (candidate) => candidate.tab_id === tabId && isUsableZellijTiledPane(candidate) && typeof candidate.id === "number", ); if (!pane) { throw new Error(`Could not find initial pane for zellij tab ${tabId}`); } const surface = `pane:${pane.id}`; try { zellijActionSync(["rename-pane", name], surface); } catch { // Optional. } return surface; } catch (error) { try { zellijActionSync(["close-tab", "--tab-id", String(tabId)]); } catch { // Best effort cleanup for tabs created before post-creation inspection failed. } throw error; } } // ── Zellij surface 锁 ── function zellijSurfaceLockPath(): string { const session = (process.env.ZELLIJ_SESSION_NAME ?? process.env.ZELLIJ ?? "default").replace( /[^A-Za-z0-9_.-]/g, "_", ); return join(tmpdir(), `pi-zellij-surface-${session}.lock`); } function withZellijSurfaceLock(callback: () => T): T { // 复用 shared.ts 的统一文件锁(mkdir 原子获取 + stale 检测 + finally 释放) return withFileLock( zellijSurfaceLockPath(), { timeoutMs: 10000, retryMs: 50, staleMs: 30000 }, callback, ); } function createZellijSurfaceUnlocked(name: string): string { const parentPaneIdRaw = process.env.ZELLIJ_PANE_ID; const parentPaneId = parentPaneIdRaw ? Number(parentPaneIdRaw) : NaN; const minColumns = envPositiveInteger( "PI_SUBAGENT_ZELLIJ_MIN_COLUMNS", DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS, ); const minRows = envPositiveInteger( "PI_SUBAGENT_ZELLIJ_MIN_ROWS", DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS, ); const plan = Number.isInteger(parentPaneId) ? selectZellijPlacement(readZellijPanes(), parentPaneId, minColumns, minRows) : null; if (plan?.mode === "split") { return createZellijTiledPane(name, plan.tabId); } if (plan?.mode === "stack") { return createZellijStackedPane(name, `pane:${plan.targetPaneId}`); } return createZellijTab(name); } function createZellijSurface(name: string): string { return withZellijSurfaceLock(() => createZellijSurfaceUnlocked(name)); } // ── BackendOps ── export const ops: BackendOps = { create(name: string): string { return createZellijSurface(name); }, createSplit( name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string, ): string { const directionArg = direction === "left" || direction === "right" ? "right" : "down"; const args = ["new-pane", "--direction", directionArg, "--name", name, "--cwd", process.cwd()]; let rawId: string; try { rawId = zellijActionSync(args, fromSurface).trim(); } catch { if (!fromSurface) throw new Error("Failed to create zellij pane"); rawId = zellijActionSync(args).trim(); } const surface = parseZellijPaneSurface(rawId, "new-pane"); if (direction === "left" || direction === "up") { try { zellijActionSync(["move-pane", direction], surface); } catch { // Optional layout polish. } } try { zellijActionSync(["rename-pane", name], surface); } catch { // Optional. } return surface; }, send(surface: string, command: string): void { zellijActionSync(["write-chars", command], surface); zellijActionSync(["write", "13"], surface); }, sendEscape(surface: string): void { zellijActionSync(["write", "27"], surface); }, read(surface: string, lines = 50): string { const paneId = zellijPaneId(surface); const raw = execFileSync( "zellij", ["action", "dump-screen", "--pane-id", paneId], { encoding: "utf8" }, ); return tailLines(raw, lines); }, async readAsync(surface: string, lines = 50): Promise { const paneId = zellijPaneId(surface); const { stdout } = await execFileAsync( "zellij", ["action", "dump-screen", "--pane-id", paneId], { encoding: "utf8" }, ); return tailLines(stdout, lines); }, close(surface: string): void { zellijActionSync(["close-pane"], surface); }, rename(surface: string, name: string): void { zellijActionSync(["rename-pane", name], surface); }, };