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.
- package/.tuiboard/config.example.yaml +29 -9
- package/CHANGELOG.md +105 -0
- package/README.md +63 -30
- package/package.json +3 -2
- package/src/config/loader.ts +32 -7
- package/src/input/handleKey.ts +71 -51
- package/src/input/open-session.ts +403 -0
- package/src/store/agent-adapters/claude-code.ts +348 -0
- package/src/store/agent-adapters/codex.ts +405 -0
- package/src/store/agent-adapters/index.ts +17 -0
- package/src/store/agent-adapters/opencode.ts +195 -0
- package/src/store/agent-adapters/pi.ts +253 -0
- package/src/store/agents.ts +139 -336
- package/src/store/index.ts +63 -7
- package/src/ui/AgentRow.tsx +147 -41
- package/src/ui/AgentsBar.tsx +8 -3
- package/src/ui/BoardView.tsx +35 -4
- package/src/ui/Modal.tsx +263 -68
- package/src/ui/agent-line.ts +95 -0
- package/src/views/AgentsOnly.tsx +11 -5
package/src/input/handleKey.ts
CHANGED
|
@@ -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.
|
|
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
|
|
502
|
+
// Open (resume) the selected session in a new terminal tab/window.
|
|
481
503
|
const target = sessions[ui.row];
|
|
482
|
-
if (target) void
|
|
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 (
|
|
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)
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
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
|
|
878
|
-
|
|
879
|
-
cwd
|
|
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
|
|
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
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|