clanker-agent/packages/pi-teams/src/adapters/wezterm-adapter.ts
Harivansh Rathi 0250f72976 move pi-mono into companion-cloud as apps/companion-os
- Copy all pi-mono source into apps/companion-os/
- Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases
- Update deploy-staging.yml to build pi from source (bun compile) before Docker build
- Add apps/companion-os/** to path triggers
- No more cross-repo dispatch needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:22:50 -08:00

366 lines
9.7 KiB
TypeScript

/**
* WezTerm Terminal Adapter
*
* Implements the TerminalAdapter interface for WezTerm terminal emulator.
* Uses wezterm cli split-pane for pane management.
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class WezTermAdapter implements TerminalAdapter {
readonly name = "WezTerm";
// Common paths where wezterm CLI might be found
private possiblePaths = [
"wezterm", // In PATH
"/Applications/WezTerm.app/Contents/MacOS/wezterm", // macOS
"/usr/local/bin/wezterm", // Linux/macOS common
"/usr/bin/wezterm", // Linux system
];
private weztermPath: string | null = null;
private findWeztermBinary(): string | null {
if (this.weztermPath !== null) {
return this.weztermPath;
}
for (const path of this.possiblePaths) {
try {
const result = execCommand(path, ["--version"]);
if (result.status === 0) {
this.weztermPath = path;
return path;
}
} catch {
// Continue to next path
}
}
this.weztermPath = null;
return null;
}
detect(): boolean {
if (!process.env.WEZTERM_PANE || process.env.TMUX || process.env.ZELLIJ) {
return false;
}
return this.findWeztermBinary() !== null;
}
/**
* Get all panes in the current tab to determine layout state.
*/
private getPanes(): any[] {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return [];
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return [];
try {
const allPanes = JSON.parse(result.stdout);
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
// Find the tab of the current pane
const currentPane = allPanes.find(
(p: any) => p.pane_id === currentPaneId,
);
if (!currentPane) return [];
// Return all panes in the same tab
return allPanes.filter((p: any) => p.tab_id === currentPane.tab_id);
} catch {
return [];
}
}
spawn(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const panes = this.getPanes();
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
let weztermArgs: string[];
// First pane: split to the right with 50% (matches iTerm2/tmux behavior)
const isFirstPane = panes.length === 1;
if (isFirstPane) {
weztermArgs = [
"cli",
"split-pane",
"--right",
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
} else {
// Subsequent teammates stack in the sidebar on the right.
// currentPaneId (id 0) is the main pane on the left.
// All other panes are in the sidebar.
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
const sidebarPanes = panes
.filter((p) => p.pane_id !== currentPaneId)
.sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first)
// To add a new pane to the bottom of the sidebar stack:
// We always split the BOTTOM-MOST pane (sidebarPanes[0])
// and use 50% so the new pane and the previous bottom pane are equal.
// This progressively fills the sidebar from top to bottom.
const targetPane = sidebarPanes[0];
weztermArgs = [
"cli",
"split-pane",
"--bottom",
"--pane-id",
targetPane.pane_id.toString(),
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
}
const result = execCommand(weztermBin, weztermArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn failed: ${result.stderr}`);
}
// New: After spawning, tell WezTerm to equalize the panes in this tab
// This ensures that regardless of the split math, they all end up the same height.
try {
execCommand(weztermBin, ["cli", "zoom-pane", "--unzoom"]); // Ensure not zoomed
// WezTerm doesn't have a single "equalize" command like tmux,
// but splitting with no percentage usually balances, or we can use
// the 'AdjustPaneSize' sequence.
// For now, let's stick to the 50/50 split of the LAST pane which is most reliable.
} catch {}
const paneId = result.stdout.trim();
return `wezterm_${paneId}`;
}
kill(paneId: string): void {
if (!paneId?.startsWith("wezterm_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermId = paneId.replace("wezterm_", "");
try {
execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", weztermId]);
} catch {}
}
isAlive(paneId: string): boolean {
if (!paneId?.startsWith("wezterm_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermId = parseInt(paneId.replace("wezterm_", ""), 10);
const panes = this.getPanes();
return panes.some((p) => p.pane_id === weztermId);
}
setTitle(title: string): void {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
try {
execCommand(weztermBin, ["cli", "set-tab-title", title]);
} catch {}
}
/**
* WezTerm supports spawning separate OS windows via CLI
*/
supportsWindows(): boolean {
return this.findWeztermBinary() !== null;
}
/**
* Spawn a new separate OS window with the given options.
* Uses `wezterm cli spawn --new-window` and sets the window title.
*/
spawnWindow(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
// Format window title as "teamName: agentName" if teamName is provided
const windowTitle = options.teamName
? `${options.teamName}: ${options.name}`
: options.name;
// Spawn a new window
const spawnArgs = [
"cli",
"spawn",
"--new-window",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
const result = execCommand(weztermBin, spawnArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn-window failed: ${result.stderr}`);
}
// The output is the pane ID, we need to find the window ID
const paneId = result.stdout.trim();
// Query to get window ID from pane ID
const windowId = this.getWindowIdFromPaneId(parseInt(paneId, 10));
// Set the window title if we found the window
if (windowId !== null) {
this.setWindowTitle(`wezterm_win_${windowId}`, windowTitle);
}
return `wezterm_win_${windowId || paneId}`;
}
/**
* Get window ID from a pane ID by querying WezTerm
*/
private getWindowIdFromPaneId(paneId: number): number | null {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return null;
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return null;
try {
const allPanes = JSON.parse(result.stdout);
const pane = allPanes.find((p: any) => p.pane_id === paneId);
return pane?.window_id ?? null;
} catch {
return null;
}
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
execCommand(weztermBin, [
"cli",
"set-window-title",
"--window-id",
weztermWindowId,
title,
]);
} catch {
// Silently fail
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
// WezTerm doesn't have a direct kill-window command, so we kill all panes in the window
const result = execCommand(weztermBin, [
"cli",
"list",
"--format",
"json",
]);
if (result.status !== 0) return;
const allPanes = JSON.parse(result.stdout);
const windowPanes = allPanes.filter(
(p: any) => p.window_id.toString() === weztermWindowId,
);
for (const pane of windowPanes) {
execCommand(weztermBin, [
"cli",
"kill-pane",
"--pane-id",
pane.pane_id.toString(),
]);
}
} catch {
// Silently fail
}
}
/**
* Check if a window is still alive/active.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId?.startsWith("wezterm_win_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
const result = execCommand(weztermBin, [
"cli",
"list",
"--format",
"json",
]);
if (result.status !== 0) return false;
const allPanes = JSON.parse(result.stdout);
return allPanes.some(
(p: any) => p.window_id.toString() === weztermWindowId,
);
} catch {
return false;
}
}
}