mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +00:00
298 lines
7.4 KiB
TypeScript
298 lines
7.4 KiB
TypeScript
/**
|
|
* iTerm2 Terminal Adapter
|
|
*
|
|
* Implements the TerminalAdapter interface for iTerm2 terminal emulator.
|
|
* Uses AppleScript for all operations.
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter";
|
|
|
|
/**
|
|
* Context needed for iTerm2 spawning (tracks last pane for layout)
|
|
*/
|
|
export interface Iterm2SpawnContext {
|
|
/** ID of the last spawned session, used for layout decisions */
|
|
lastSessionId?: string;
|
|
}
|
|
|
|
export class Iterm2Adapter implements TerminalAdapter {
|
|
readonly name = "iTerm2";
|
|
private spawnContext: Iterm2SpawnContext = {};
|
|
|
|
detect(): boolean {
|
|
return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ;
|
|
}
|
|
|
|
/**
|
|
* Helper to execute AppleScript via stdin to avoid escaping issues with -e
|
|
*/
|
|
private runAppleScript(script: string): { stdout: string; stderr: string; status: number | null } {
|
|
const result = spawnSync("osascript", ["-"], {
|
|
input: script,
|
|
encoding: "utf-8",
|
|
});
|
|
return {
|
|
stdout: result.stdout?.toString() ?? "",
|
|
stderr: result.stderr?.toString() ?? "",
|
|
status: result.status,
|
|
};
|
|
}
|
|
|
|
spawn(options: SpawnOptions): string {
|
|
const envStr = Object.entries(options.env)
|
|
.filter(([k]) => k.startsWith("PI_"))
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(" ");
|
|
|
|
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
|
|
const escapedCmd = itermCmd.replace(/"/g, '\\"');
|
|
|
|
let script: string;
|
|
|
|
if (!this.spawnContext.lastSessionId) {
|
|
script = `tell application "iTerm2"
|
|
tell current session of current window
|
|
set newSession to split vertically with default profile
|
|
tell newSession
|
|
write text "${escapedCmd}"
|
|
return id
|
|
end tell
|
|
end tell
|
|
end tell`;
|
|
} else {
|
|
script = `tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
repeat with aTab in tabs of aWindow
|
|
repeat with aSession in sessions of aTab
|
|
if id of aSession is "${this.spawnContext.lastSessionId}" then
|
|
tell aSession
|
|
set newSession to split horizontally with default profile
|
|
tell newSession
|
|
write text "${escapedCmd}"
|
|
return id
|
|
end tell
|
|
end tell
|
|
end if
|
|
end repeat
|
|
end repeat
|
|
end repeat
|
|
end tell`;
|
|
}
|
|
|
|
const result = this.runAppleScript(script);
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
|
|
}
|
|
|
|
const sessionId = result.stdout.toString().trim();
|
|
this.spawnContext.lastSessionId = sessionId;
|
|
|
|
return `iterm_${sessionId}`;
|
|
}
|
|
|
|
kill(paneId: string): void {
|
|
if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
|
|
return;
|
|
}
|
|
|
|
const itermId = paneId.replace("iterm_", "");
|
|
const script = `tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
repeat with aTab in tabs of aWindow
|
|
repeat with aSession in sessions of aTab
|
|
if id of aSession is "${itermId}" then
|
|
close aSession
|
|
return "Closed"
|
|
end if
|
|
end repeat
|
|
end repeat
|
|
end repeat
|
|
end tell`;
|
|
|
|
try {
|
|
this.runAppleScript(script);
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
|
|
isAlive(paneId: string): boolean {
|
|
if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
|
|
return false;
|
|
}
|
|
|
|
const itermId = paneId.replace("iterm_", "");
|
|
const script = `tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
repeat with aTab in tabs of aWindow
|
|
repeat with aSession in sessions of aTab
|
|
if id of aSession is "${itermId}" then
|
|
return "Alive"
|
|
end if
|
|
end repeat
|
|
end repeat
|
|
end repeat
|
|
end tell`;
|
|
|
|
try {
|
|
const result = this.runAppleScript(script);
|
|
return result.stdout.includes("Alive");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
setTitle(title: string): void {
|
|
const escapedTitle = title.replace(/"/g, '\\"');
|
|
const script = `tell application "iTerm2" to tell current session of current window
|
|
set name to "${escapedTitle}"
|
|
end tell`;
|
|
try {
|
|
this.runAppleScript(script);
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* iTerm2 supports spawning separate OS windows via AppleScript
|
|
*/
|
|
supportsWindows(): boolean {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Spawn a new separate OS window with the given options.
|
|
*/
|
|
spawnWindow(options: SpawnOptions): string {
|
|
const envStr = Object.entries(options.env)
|
|
.filter(([k]) => k.startsWith("PI_"))
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(" ");
|
|
|
|
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
|
|
const escapedCmd = itermCmd.replace(/"/g, '\\"');
|
|
|
|
const windowTitle = options.teamName ? `${options.teamName}: ${options.name}` : options.name;
|
|
|
|
const escapedTitle = windowTitle.replace(/"/g, '\\"');
|
|
|
|
const script = `tell application "iTerm2"
|
|
set newWindow to (create window with default profile)
|
|
tell current session of newWindow
|
|
-- Set the session name (tab title)
|
|
set name to "${escapedTitle}"
|
|
-- Set window title via escape sequence (OSC 2)
|
|
-- We use double backslashes for AppleScript to emit a single backslash to the shell
|
|
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
|
|
-- Execute the command
|
|
write text "cd '${options.cwd}' && ${escapedCmd}"
|
|
return id of newWindow
|
|
end tell
|
|
end tell`;
|
|
|
|
const result = this.runAppleScript(script);
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
|
|
}
|
|
|
|
const windowId = result.stdout.toString().trim();
|
|
return `iterm_win_${windowId}`;
|
|
}
|
|
|
|
/**
|
|
* Set the title of a specific window.
|
|
*/
|
|
setWindowTitle(windowId: string, title: string): void {
|
|
if (!windowId || !windowId.startsWith("iterm_win_")) {
|
|
return;
|
|
}
|
|
|
|
const itermId = windowId.replace("iterm_win_", "");
|
|
const escapedTitle = title.replace(/"/g, '\\"');
|
|
|
|
const script = `tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
if id of aWindow is "${itermId}" then
|
|
tell current session of aWindow
|
|
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
|
|
end tell
|
|
exit repeat
|
|
end if
|
|
end repeat
|
|
end tell`;
|
|
|
|
try {
|
|
this.runAppleScript(script);
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kill/terminate a window.
|
|
*/
|
|
killWindow(windowId: string): void {
|
|
if (!windowId || !windowId.startsWith("iterm_win_")) {
|
|
return;
|
|
}
|
|
|
|
const itermId = windowId.replace("iterm_win_", "");
|
|
const script = `tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
if id of aWindow is "${itermId}" then
|
|
close aWindow
|
|
return "Closed"
|
|
end if
|
|
end repeat
|
|
end tell`;
|
|
|
|
try {
|
|
this.runAppleScript(script);
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a window is still alive/active.
|
|
*/
|
|
isWindowAlive(windowId: string): boolean {
|
|
if (!windowId || !windowId.startsWith("iterm_win_")) {
|
|
return false;
|
|
}
|
|
|
|
const itermId = windowId.replace("iterm_win_", "");
|
|
const script = `tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
if id of aWindow is "${itermId}" then
|
|
return "Alive"
|
|
end if
|
|
end repeat
|
|
end tell`;
|
|
|
|
try {
|
|
const result = this.runAppleScript(script);
|
|
return result.stdout.includes("Alive");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the spawn context (used to restore state when needed)
|
|
*/
|
|
setSpawnContext(context: Iterm2SpawnContext): void {
|
|
this.spawnContext = context;
|
|
}
|
|
|
|
/**
|
|
* Get current spawn context (useful for persisting state)
|
|
*/
|
|
getSpawnContext(): Iterm2SpawnContext {
|
|
return { ...this.spawnContext };
|
|
}
|
|
}
|