tuiboard 0.10.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.
@@ -14,6 +14,15 @@
14
14
  */
15
15
 
16
16
  import { isHiddenColumn } from "~/config/loader";
17
+ import {
18
+ LAUNCHER_NAME,
19
+ describePlan,
20
+ detectLauncher,
21
+ planLaunch,
22
+ runLaunchPlan,
23
+ systemLaunchEnv,
24
+ } from "~/input/open-session";
25
+ import { HARNESS, type AgentSession } from "~/store/agents";
17
26
  import { googleTokenCanWrite } from "~/store/calendar";
18
27
  import { isTask } from "~/parser/markdown";
19
28
  import {
@@ -104,6 +113,12 @@ export function handleKey(
104
113
  store.closeModal();
105
114
  return;
106
115
  }
116
+ // Help modal: j/k (or arrows) scroll its keyboard reference.
117
+ if (ui.modal.kind === "help") {
118
+ if (key.name === "j" || key.name === "down") { store.setHelpScroll(ui.helpScroll + 1); return; }
119
+ if (key.name === "k" || key.name === "up") { store.setHelpScroll(ui.helpScroll - 1); return; }
120
+ return;
121
+ }
107
122
  // New-event modal: step 1 typing goes to the <input>; step 2 (no input
108
123
  // focused) is the calendar picker, driven here.
109
124
  if (ui.modal.kind === "event") {
@@ -221,6 +236,13 @@ export function handleKey(
221
236
  // Cycle the board filter — affects which open tasks show up in board
222
237
  // columns. Mirrors Python kanban `action_cycle_filter`. Cycle order:
223
238
  // all → today → overdue → tomorrow → followup → all.
239
+ // In the Agents zone `f` filters sessions by harness instead.
240
+ if (key.name === "f" && ui.activeZone === "agents") {
241
+ const next = store.cycleAgentsFilter();
242
+ const label = next === "all" ? "all harnesses" : `${HARNESS[next].code} · ${HARNESS[next].name}`;
243
+ store.flashBanner("info", `Agents: ${label}`);
244
+ return;
245
+ }
224
246
  if (key.name === "f") {
225
247
  const cycle = ["all", "today", "overdue", "tomorrow", "followup"] as const;
226
248
  const idx = cycle.indexOf(ui.filter);
@@ -471,24 +493,25 @@ function handleTimelineZone(
471
493
 
472
494
  function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
473
495
  const ui = store.state.ui;
474
- const sessions = store.agents.sessions();
496
+ const sessions = store.agentSessions();
475
497
  if (key.name === "j" || key.name === "down") {
476
498
  store.setCursor(0, Math.min(sessions.length - 1, ui.row + 1));
477
499
  } else if (key.name === "k" || key.name === "up") {
478
500
  store.setCursor(0, Math.max(0, ui.row - 1));
479
501
  } else if (key.name === "enter" || key.name === "return") {
480
- // Open (resume) the selected session in a new WezTerm tab.
502
+ // Open (resume) the selected session in a new terminal tab/window.
481
503
  const target = sessions[ui.row];
482
- if (target) void openSessionInWezterm(store, target.cwd, target.sessionId);
504
+ if (target) void openSession(store, target);
483
505
  } else if (key.name === "c") {
484
506
  // Copy a one-paste "cd + resume" command for the selected session, so you
485
507
  // can drop it into any tab/pane anywhere and land in the right directory
486
- // resuming the right session (no WezTerm dependency, unlike Enter).
508
+ // resuming the right session (works in any terminal, unlike Enter).
487
509
  const target = sessions[ui.row];
488
510
  if (target) {
489
511
  const cmd = store.config.copyResumeCommand
490
512
  .replaceAll("{cwd}", target.cwd)
491
- .replaceAll("{sessionId}", target.sessionId);
513
+ .replaceAll("{sessionId}", target.sessionId)
514
+ .replaceAll("{resume}", target.resumeCommand);
492
515
  copyToClipboard(cmd).then(
493
516
  () => store.flashBanner("info", `📋 Copied resume command (${target.sessionId.slice(0, 8)})`),
494
517
  (err) => store.flashBanner("error", `Copy failed: ${err}`),
@@ -861,33 +884,26 @@ function fmtHm(m: number): string {
861
884
  }
862
885
 
863
886
  /**
864
- * Open (resume) a Claude Code session in a new WezTerm tab.
865
- *
866
- * Two steps:
867
- * 1. `wezterm cli spawn --cwd <cwd>` opens a new tab running your DEFAULT
868
- * shell in the session's directory (prints the new pane id).
869
- * 2. `wezterm cli send-text` types `claude --resume <id>` + Enter into it.
870
- *
871
- * Running it through the interactive shell (rather than `spawn -- claude …`
872
- * directly) means `claude` gets your full shell environment — PATH, env vars,
873
- * any wrapper — which is why the direct form exited 1. And if `claude` still
874
- * errors, you're left at a live prompt that shows it instead of a vanishing
875
- * tab. Failures (not inside WezTerm, `wezterm` off PATH) surface as a banner.
887
+ * Open (resume) an agent session in a new tab/window of the terminal tuiboard
888
+ * runs in — tmux, herdr, WezTerm, Windows Terminal, Ghostty, or the OS default
889
+ * — inside the user's shell (see open-session.ts). Config `resume_terminal` /
890
+ * `resume_shell` force them. When nothing can open it, the resume command lands
891
+ * on the clipboard instead. The outcome is kept for the session's detail modal.
876
892
  */
877
- async function openSessionInWezterm(
878
- store: TuiStore,
879
- cwd: string,
880
- sessionId: string,
881
- ): Promise<void> {
882
- const { spawn, spawnSync } = await import("node:child_process");
893
+ async function openSession(store: TuiStore, session: AgentSession): Promise<void> {
894
+ const { spawn } = await import("node:child_process");
895
+ const { cwd, sessionId } = session;
883
896
 
884
897
  // Custom override (config `resume_command`): an argv array with {cwd} /
885
- // {sessionId} placeholders, spawned directly (no shell). Lets you launch a
886
- // personal terminal layout without baking it into the distributed tool.
898
+ // {sessionId} / {resume} placeholders, spawned directly (no shell). Lets you
899
+ // launch a personal terminal layout without baking it into the distributed tool.
887
900
  const custom = store.config.resumeCommand;
888
901
  if (custom && custom.length > 0) {
889
902
  const argv = custom.map((arg) =>
890
- arg.replaceAll("{cwd}", cwd).replaceAll("{sessionId}", sessionId),
903
+ arg
904
+ .replaceAll("{cwd}", cwd)
905
+ .replaceAll("{sessionId}", sessionId)
906
+ .replaceAll("{resume}", session.resumeCommand),
891
907
  );
892
908
  const [cmd, ...rest] = argv;
893
909
  try {
@@ -903,39 +919,43 @@ async function openSessionInWezterm(
903
919
  store.flashBanner("error", `resume_command failed: ${e.message}`),
904
920
  );
905
921
  child.unref();
906
- store.flashBanner("info", `↗ Opening session (${sessionId.slice(0, 8)})`);
922
+ store.flashBanner("info", `↗ Opening session via resume_command (${sessionId.slice(0, 8)})`);
907
923
  } catch (e) {
908
924
  store.flashBanner("error", `resume_command failed: ${String(e)}`);
909
925
  }
910
926
  return;
911
927
  }
912
928
 
913
- try {
914
- const spawned = spawnSync("wezterm", ["cli", "spawn", "--cwd", cwd], {
915
- encoding: "utf8",
916
- windowsHide: true,
917
- });
918
- if (spawned.error) {
919
- store.flashBanner("error", `WezTerm launch failed: ${spawned.error.message}`);
920
- return;
921
- }
922
- if (spawned.status !== 0) {
923
- store.flashBanner(
924
- "error",
925
- `WezTerm spawn failed: ${(spawned.stderr || "").trim() || `exit ${spawned.status}`}`,
926
- );
927
- return;
928
- }
929
- const paneId = spawned.stdout.trim();
930
- // Type the resume command into the fresh pane (\r submits, like Enter).
931
- spawnSync(
932
- "wezterm",
933
- ["cli", "send-text", "--pane-id", paneId, "--no-paste"],
934
- { input: `claude --resume ${sessionId}\r`, encoding: "utf8", windowsHide: true },
929
+ const launchEnv = systemLaunchEnv();
930
+ const forced = store.config.resumeTerminal;
931
+ const launcher = forced === "auto" ? detectLauncher(launchEnv) : forced;
932
+ const fail = async (why: string) => {
933
+ const copied = await copyToClipboard(session.resumeCommand).then(
934
+ () => true,
935
+ () => false,
935
936
  );
936
- store.flashBanner("info", `↗ Opened session in WezTerm (${sessionId.slice(0, 8)})`);
937
+ const text = copied ? `${why} — resume command copied, paste it in ${cwd}` : why;
938
+ store.setLastLaunch(sessionId, false, text);
939
+ store.flashBanner(copied ? "warn" : "error", text);
940
+ };
941
+
942
+ if (!launcher) {
943
+ await fail("No supported terminal detected (set resume_terminal)");
944
+ return;
945
+ }
946
+ const plan = planLaunch(
947
+ launcher,
948
+ { cwd, resume: session.resumeCommand, shell: store.config.resumeShell },
949
+ launchEnv,
950
+ );
951
+ try {
952
+ await runLaunchPlan(plan);
953
+ const text = `↗ Opened session in ${LAUNCHER_NAME[launcher]} (${sessionId.slice(0, 8)})`;
954
+ store.setLastLaunch(sessionId, true, `${text}\n${describePlan(plan)}`);
955
+ store.flashBanner("info", text);
937
956
  } catch (e) {
938
- store.flashBanner("error", `WezTerm launch failed: ${String(e)}`);
957
+ const msg = e instanceof Error ? e.message : String(e);
958
+ await fail(`${LAUNCHER_NAME[launcher]} launch failed: ${msg}`);
939
959
  }
940
960
  }
941
961
 
@@ -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
+ }