mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-21 18:05:14 +00:00
- 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>
366 lines
9.7 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|