tuiboard 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Open (resume) an agent session in a new tab/window of the terminal tuiboard
3
+ * runs in. There's no terminal-agnostic "open a tab" — each terminal has its
4
+ * own IPC — so we detect the environment and build a launch plan for it.
5
+ * The session runs inside the user's shell (see `resolveShell`), which stays
6
+ * open when the agent exits.
7
+ *
8
+ * Planning is pure (env + platform in, argv out) so every OS's plan is unit
9
+ * tested anywhere; `runLaunchPlan` does the spawning.
10
+ */
11
+
12
+ import { existsSync, lstatSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ export const LAUNCHERS = [
16
+ "tmux",
17
+ "herdr",
18
+ "wezterm",
19
+ "windows-terminal",
20
+ "ghostty",
21
+ "xdg-terminal-exec",
22
+ "windows-console",
23
+ "macos-terminal",
24
+ ] as const;
25
+ export type Launcher = (typeof LAUNCHERS)[number];
26
+
27
+ export const LAUNCHER_NAME: Record<Launcher, string> = {
28
+ tmux: "tmux",
29
+ herdr: "herdr",
30
+ wezterm: "WezTerm",
31
+ "windows-terminal": "Windows Terminal",
32
+ ghostty: "Ghostty",
33
+ "xdg-terminal-exec": "your default terminal",
34
+ "windows-console": "a new console window",
35
+ "macos-terminal": "Terminal.app",
36
+ };
37
+
38
+ export const SHELLS = ["bash", "zsh", "fish", "nu", "pwsh", "powershell", "cmd"] as const;
39
+ export type Shell = (typeof SHELLS)[number];
40
+
41
+ export interface LaunchEnv {
42
+ env: Record<string, string | undefined>;
43
+ platform: NodeJS.Platform;
44
+ /** Is this program launchable (on PATH, or a Windows app alias)? */
45
+ has: (cmd: string) => boolean;
46
+ /** Full path of a program on PATH. */
47
+ which?: (cmd: string) => string | undefined;
48
+ /** Does this file exist? */
49
+ exists?: (path: string) => boolean;
50
+ }
51
+
52
+ /**
53
+ * Pick the launcher for this environment. Multiplexers first (a tmux inside
54
+ * Ghostty should open a tmux window, not a Ghostty one), then terminals by
55
+ * their own env markers, then a per-OS generic fallback.
56
+ */
57
+ export function detectLauncher({ env, platform, has }: LaunchEnv): Launcher | undefined {
58
+ if (env.TMUX) return "tmux";
59
+ if (env.HERDR_ENV) return "herdr";
60
+ if (env.WEZTERM_PANE) return "wezterm";
61
+ if (env.WT_SESSION) return "windows-terminal";
62
+ if (env.TERM_PROGRAM === "ghostty") return "ghostty";
63
+ if (platform === "win32") return has("wt") ? "windows-terminal" : "windows-console";
64
+ if (platform === "darwin") return "macos-terminal";
65
+ if (has("xdg-terminal-exec")) return "xdg-terminal-exec";
66
+ return undefined;
67
+ }
68
+
69
+ /**
70
+ * Is `cmd` launchable? `Bun.which` skips Windows App Execution Aliases
71
+ * (`wt.exe`, Store `pwsh.exe`: zero-byte reparse points in
72
+ * %LOCALAPPDATA%\Microsoft\WindowsApps), so look for those explicitly.
73
+ */
74
+ export function hasCommand(cmd: string): boolean {
75
+ if (Bun.which(cmd) !== null) return true;
76
+ if (process.platform !== "win32" || !process.env.LOCALAPPDATA) return false;
77
+ try {
78
+ lstatSync(join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps", `${cmd}.exe`));
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /** The real environment, for the key handler and the dev script. */
86
+ export function systemLaunchEnv(): LaunchEnv {
87
+ return {
88
+ env: process.env,
89
+ platform: process.platform,
90
+ has: hasCommand,
91
+ which: (c) => Bun.which(c) ?? undefined,
92
+ exists: existsSync,
93
+ };
94
+ }
95
+
96
+ // ─── Shells ─────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * How the new tab's program is chosen: a named shell, or (POSIX `auto`) a
100
+ * plain `sh` that hands over to `$SHELL` — already the user's shell.
101
+ */
102
+ export type ResolvedShell =
103
+ | { kind: "posix-default" }
104
+ | { kind: Shell; path: string };
105
+
106
+ /** Windows: Git for Windows' `bin\bash.exe` (the login wrapper), never System32's WSL bash. */
107
+ export function findGitBash({ env, which, exists = () => false }: LaunchEnv): string | undefined {
108
+ const candidates: string[] = [];
109
+ // Set by Git's launchers (git-bash.exe, bin\bash.exe) to the install dir.
110
+ if (env.EXEPATH) candidates.push(`${env.EXEPATH}\\bin\\bash.exe`);
111
+ const onPath = which?.("bash");
112
+ if (onPath && /\\git\\/i.test(onPath) && !/\\system32\\/i.test(onPath)) {
113
+ // …\Git\usr\bin\bash.exe is the bare MSYS binary; …\Git\bin\bash.exe sets up the login env.
114
+ candidates.push(onPath.replace(/\\usr\\bin\\bash\.exe$/i, "\\bin\\bash.exe"));
115
+ }
116
+ for (const pf of [env.ProgramFiles, env.ProgramW6432, "C:\\Program Files"]) {
117
+ if (pf) candidates.push(`${pf}\\Git\\bin\\bash.exe`);
118
+ }
119
+ return candidates.find((c) => exists(c));
120
+ }
121
+
122
+ /**
123
+ * The shell the session should run in. `auto` = the shell tuiboard was
124
+ * started from: on Windows Git Bash (`MSYSTEM`) or Nushell (`NU_VERSION`),
125
+ * else PowerShell; elsewhere `$SHELL` via the POSIX default.
126
+ */
127
+ export function resolveShell(choice: "auto" | Shell, le: LaunchEnv): ResolvedShell {
128
+ const { env, platform, has } = le;
129
+ if (choice === "auto") {
130
+ if (platform !== "win32") return { kind: "posix-default" };
131
+ if (env.MSYSTEM) {
132
+ const bash = findGitBash(le);
133
+ if (bash) return { kind: "bash", path: bash };
134
+ }
135
+ if (env.NU_VERSION && has("nu")) return { kind: "nu", path: "nu" };
136
+ return has("pwsh") ? { kind: "pwsh", path: "pwsh" } : { kind: "powershell", path: "powershell" };
137
+ }
138
+ if (choice === "bash" && platform === "win32") {
139
+ const bash = findGitBash(le);
140
+ if (bash) return { kind: "bash", path: bash };
141
+ }
142
+ return { kind: choice, path: choice };
143
+ }
144
+
145
+ /** argv that runs `resume` in `shell`, leaving that shell open afterwards. */
146
+ export function shellArgv(shell: ResolvedShell, resume: string): string[] {
147
+ switch (shell.kind) {
148
+ case "posix-default":
149
+ return ["sh", "-c", `${resume}; exec "\${SHELL:-sh}"`];
150
+ case "bash":
151
+ case "zsh":
152
+ case "fish":
153
+ // Login + interactive so the user's PATH/aliases are loaded, then
154
+ // replace the finished command with a fresh interactive shell.
155
+ return [shell.path, "-l", "-i", "-c", `${resume}; exec ${shell.kind === "bash" ? "bash" : shell.kind} -l -i`];
156
+ case "nu":
157
+ return [shell.path, "-e", resume];
158
+ case "pwsh":
159
+ case "powershell":
160
+ return [shell.path, "-NoExit", "-Command", resume];
161
+ case "cmd":
162
+ return [shell.path, "/k", resume];
163
+ }
164
+ }
165
+
166
+ // ─── Plans ──────────────────────────────────────────────────────────────────
167
+
168
+ export interface LaunchStep {
169
+ cmd: string;
170
+ args: string[];
171
+ /** Text piped to stdin. */
172
+ input?: string;
173
+ /** Long-lived GUI process: spawn detached and don't wait for it. */
174
+ detached?: boolean;
175
+ /** Sync step timeout (ms). */
176
+ timeoutMs?: number;
177
+ /**
178
+ * Extracts an id from this step's stdout; later steps' `{id}` args are
179
+ * replaced with it.
180
+ */
181
+ captureId?: (stdout: string) => string | undefined;
182
+ }
183
+
184
+ export interface LaunchTarget {
185
+ cwd: string;
186
+ /** The agent's resume command, e.g. `codex resume <id>`. */
187
+ resume: string;
188
+ /** Shell to run it in (defaults to `auto`). */
189
+ shell?: "auto" | Shell;
190
+ }
191
+
192
+ function shQuote(s: string): string {
193
+ return `'${s.replaceAll("'", `'\\''`)}'`;
194
+ }
195
+
196
+ function appleScriptString(s: string): string {
197
+ return `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
198
+ }
199
+
200
+ /** Quote one argument by the Windows command-line (CommandLineToArgvW) rules. */
201
+ export function winQuoteArg(a: string): string {
202
+ if (a !== "" && !/[\s"]/.test(a)) return a;
203
+ const escaped = a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1");
204
+ return `"${escaped}"`;
205
+ }
206
+
207
+ /** `C:/Users/x` (as OpenCode stores it) → `C:\Users\x`. */
208
+ function winPath(p: string): string {
209
+ return /^[A-Za-z]:\//.test(p) ? p.replaceAll("/", "\\") : p;
210
+ }
211
+
212
+ /**
213
+ * Windows: start `file` the way Run / Explorer would — via PowerShell's
214
+ * Start-Process (ShellExecute), which resolves App Execution Aliases that
215
+ * Bun's own spawn can't find. `powershell.exe` itself is a real System32
216
+ * binary. The script travels as -EncodedCommand so no argument is re-parsed
217
+ * by a shell on the way.
218
+ */
219
+ export function windowsStart(
220
+ file: string,
221
+ args: string[],
222
+ env: LaunchEnv["env"],
223
+ workingDirectory?: string,
224
+ ): LaunchStep {
225
+ const lit = (s: string) => `'${s.replaceAll("'", "''")}'`;
226
+ const start = [
227
+ "Start-Process",
228
+ `-FilePath ${lit(file)}`,
229
+ args.length > 0 ? `-ArgumentList ${lit(args.map(winQuoteArg).join(" "))}` : "",
230
+ workingDirectory ? `-WorkingDirectory ${lit(workingDirectory)}` : "",
231
+ "-ErrorAction Stop",
232
+ ].filter(Boolean).join(" ");
233
+ const script = `try { ${start} } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }`;
234
+ const systemRoot = env.SystemRoot || env.SYSTEMROOT || "C:\\Windows";
235
+ return {
236
+ cmd: `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
237
+ args: [
238
+ "-NoProfile",
239
+ "-NonInteractive",
240
+ "-EncodedCommand",
241
+ Buffer.from(script, "utf16le").toString("base64"),
242
+ ],
243
+ // A cold PowerShell start can take several seconds.
244
+ timeoutMs: 30_000,
245
+ };
246
+ }
247
+
248
+ export function planLaunch(
249
+ launcher: Launcher,
250
+ { cwd, resume, shell: shellChoice = "auto" }: LaunchTarget,
251
+ le: LaunchEnv,
252
+ ): LaunchStep[] {
253
+ const { env, platform } = le;
254
+ const shell = resolveShell(shellChoice, le);
255
+ switch (launcher) {
256
+ case "tmux":
257
+ return [
258
+ {
259
+ cmd: "tmux",
260
+ args: ["new-window", "-P", "-F", "#{pane_id}", "-c", cwd],
261
+ captureId: (out) => out.trim() || undefined,
262
+ },
263
+ // Typed into the pane's own interactive shell (full user env), then Enter.
264
+ { cmd: "tmux", args: ["send-keys", "-t", "{id}", "-l", resume] },
265
+ { cmd: "tmux", args: ["send-keys", "-t", "{id}", "Enter"] },
266
+ ];
267
+ case "herdr": {
268
+ const herdr = env.HERDR_BIN_PATH || "herdr";
269
+ return [
270
+ {
271
+ cmd: herdr,
272
+ args: ["tab", "create", "--cwd", cwd, "--label", resume.split(" ")[0] ?? "agent"],
273
+ captureId: (out) => {
274
+ try {
275
+ return JSON.parse(out)?.result?.root_pane?.pane_id;
276
+ } catch {
277
+ return undefined;
278
+ }
279
+ },
280
+ },
281
+ { cmd: herdr, args: ["pane", "run", "{id}", resume] },
282
+ ];
283
+ }
284
+ case "wezterm":
285
+ return [
286
+ // New tab running the user's DEFAULT shell in the session's directory;
287
+ // prints the new pane id. Typing the command into that shell (rather
288
+ // than `spawn -- claude …`) gives the agent the full shell environment,
289
+ // and leaves a live prompt showing any error instead of a vanishing tab.
290
+ {
291
+ cmd: "wezterm",
292
+ args: ["cli", "spawn", "--cwd", cwd],
293
+ captureId: (out) => out.trim() || undefined,
294
+ },
295
+ {
296
+ cmd: "wezterm",
297
+ args: ["cli", "send-text", "--pane-id", "{id}", "--no-paste"],
298
+ input: `${resume}\r`,
299
+ },
300
+ ];
301
+ case "windows-terminal": {
302
+ // `-w 0` = the current window. wt treats `;` as its own command
303
+ // separator, so escape any in what it passes on.
304
+ const argv = shellArgv(shell, resume).map((a) => a.replaceAll(";", "\\;"));
305
+ return [windowsStart("wt.exe", ["-w", "0", "new-tab", "-d", winPath(cwd), ...argv], env)];
306
+ }
307
+ case "windows-console": {
308
+ // Start-Process gives a console program its own new window.
309
+ const [file, ...args] = shellArgv(shell, resume);
310
+ return [windowsStart(file!, args, env, winPath(cwd))];
311
+ }
312
+ case "ghostty":
313
+ // Ghostty has no "new tab" CLI: open a new window in the session dir.
314
+ return platform === "darwin"
315
+ ? [
316
+ {
317
+ cmd: "open",
318
+ args: ["-na", "Ghostty", "--args", `--working-directory=${cwd}`, "-e", ...shellArgv(shell, resume)],
319
+ detached: true,
320
+ },
321
+ ]
322
+ : [
323
+ {
324
+ cmd: "ghostty",
325
+ args: [`--working-directory=${cwd}`, "-e", ...shellArgv(shell, resume)],
326
+ detached: true,
327
+ },
328
+ ];
329
+ case "xdg-terminal-exec":
330
+ return [
331
+ {
332
+ cmd: "xdg-terminal-exec",
333
+ args: [`--dir=${cwd}`, ...shellArgv(shell, resume)],
334
+ detached: true,
335
+ },
336
+ ];
337
+ case "macos-terminal":
338
+ // Terminal.app types into the user's login shell itself.
339
+ return [
340
+ {
341
+ cmd: "osascript",
342
+ args: [
343
+ "-e",
344
+ `tell application "Terminal" to do script ${appleScriptString(`cd ${shQuote(cwd)} && ${resume}`)}`,
345
+ "-e",
346
+ 'tell application "Terminal" to activate',
347
+ ],
348
+ },
349
+ ];
350
+ }
351
+ }
352
+
353
+ /** Human-readable plan, for diagnostics (decodes Windows -EncodedCommand). */
354
+ export function describePlan(steps: LaunchStep[]): string {
355
+ return steps
356
+ .map((s) => {
357
+ const enc = s.args.indexOf("-EncodedCommand");
358
+ if (enc >= 0 && s.args[enc + 1]) {
359
+ return `${s.cmd} -EncodedCommand ⟨${Buffer.from(s.args[enc + 1]!, "base64").toString("utf16le")}⟩`;
360
+ }
361
+ return [s.cmd, ...s.args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" ");
362
+ })
363
+ .join("\n");
364
+ }
365
+
366
+ /**
367
+ * Run a plan. Throws with a readable message on the first failing step.
368
+ * Short IPC steps run synchronously (their output feeds the next step);
369
+ * GUI launches are detached so tuiboard doesn't wait on the new window.
370
+ */
371
+ export async function runLaunchPlan(steps: LaunchStep[]): Promise<void> {
372
+ const { spawn, spawnSync } = await import("node:child_process");
373
+ let id: string | undefined;
374
+ for (const step of steps) {
375
+ const args = step.args.map((a) => (id !== undefined ? a.replaceAll("{id}", id) : a));
376
+ const exe = Bun.which(step.cmd) ?? step.cmd;
377
+ if (step.detached) {
378
+ await new Promise<void>((resolve, reject) => {
379
+ const child = spawn(exe, args, { detached: true, stdio: "ignore", windowsHide: true });
380
+ child.once("error", (e) => reject(new Error(`${step.cmd}: ${e.message}`)));
381
+ child.once("spawn", () => {
382
+ child.unref();
383
+ resolve();
384
+ });
385
+ });
386
+ continue;
387
+ }
388
+ const res = spawnSync(exe, args, {
389
+ input: step.input,
390
+ encoding: "utf8",
391
+ windowsHide: true,
392
+ timeout: step.timeoutMs ?? 10_000,
393
+ });
394
+ if (res.error) throw new Error(`${step.cmd}: ${res.error.message}`);
395
+ if (res.status !== 0) {
396
+ throw new Error(`${step.cmd}: ${(res.stderr || "").trim() || `exit ${res.status}`}`);
397
+ }
398
+ if (step.captureId) {
399
+ id = step.captureId(res.stdout ?? "");
400
+ if (!id) throw new Error(`${step.cmd}: unexpected output`);
401
+ }
402
+ }
403
+ }