cyber-mux 0.4.0 → 0.6.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/dist/agent.d.mts +1 -1
- package/dist/agent.mjs +1 -1
- package/dist/backend-AErdtLRB.mjs +4246 -0
- package/dist/backend-AErdtLRB.mjs.map +1 -0
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +92 -101
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +309 -33
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +55 -2
- package/dist/index.mjs.map +1 -0
- package/dist/{mux-CoYDrk3v.d.mts → mux-DBpfXsdE.d.mts} +256 -20
- package/dist/mux-DBpfXsdE.d.mts.map +1 -0
- package/package.json +5 -3
- package/src/backend.ts +23 -8
- package/src/cli-options.ts +10 -1
- package/src/cli.ts +101 -3
- package/src/floating.ts +57 -0
- package/src/index.ts +10 -3
- package/src/mux-probe.ts +86 -25
- package/src/mux.cmux.ts +348 -0
- package/src/mux.herdr.ts +226 -28
- package/src/mux.otty.ts +282 -0
- package/src/mux.rmux.ts +651 -0
- package/src/mux.tmux.ts +167 -12
- package/src/mux.ts +255 -18
- package/src/mux.wezterm.ts +58 -8
- package/src/mux.zellij.ts +411 -64
- package/src/nudge.ts +2 -2
- package/src/read-window.ts +57 -0
- package/src/region-tree.ts +225 -0
- package/src/resize.ts +52 -0
- package/src/template-capture.ts +7 -132
- package/src/wait-output.ts +3 -1
- package/dist/backend-Cif257ji.mjs +0 -2345
- package/dist/backend-Cif257ji.mjs.map +0 -1
- package/dist/mux-CoYDrk3v.d.mts.map +0 -1
|
@@ -0,0 +1,4246 @@
|
|
|
1
|
+
import { h as withReason, m as nodeExec, s as normalizeWorktreePath } from "./worktree-hHuFZkpW.mjs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
//#region src/env-fallback.ts
|
|
5
|
+
/**
|
|
6
|
+
* The env-prefix fallback — the one compensation for a route that could not set env at birth.
|
|
7
|
+
*
|
|
8
|
+
* env is native at every tier on both backends EXCEPT herdr's worktree `create`/`open`, which take
|
|
9
|
+
* no env parameter (0.7.4 answers `--env` with `unknown option`). A route that hit that wall carries
|
|
10
|
+
* env the only way left: as an `env KEY=VALUE` prefix on the command the pane runs. It is a LAST
|
|
11
|
+
* resort — the values land in `ps` output and the pane's shell history — and it only works when there
|
|
12
|
+
* IS a command to ride; with none, the honest outcome is to warn, never to drop silently.
|
|
13
|
+
*
|
|
14
|
+
* This lives in one module, called by both routes that can lose env (the CLI worktree verbs and the
|
|
15
|
+
* template walk's root pane), so the rule cannot be wired on one and forgotten on the other. Only a
|
|
16
|
+
* route that lost env may call it: prefixing over a natively-set env would push the values into `ps`
|
|
17
|
+
* and shell history on every route, the exact cost the prefix exists to pay only when it must.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Single-quote a value for a shell command line. Everything is literal inside single quotes, so the
|
|
21
|
+
* only escape needed is for a single quote itself: end the quoting, emit an escaped `'`, reopen.
|
|
22
|
+
* Without this a value carrying a space or a quote would split into extra words, or unbalance the
|
|
23
|
+
* line outright.
|
|
24
|
+
*/
|
|
25
|
+
function shellQuote(value) {
|
|
26
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
27
|
+
}
|
|
28
|
+
/** `env K=V …` with a trailing space, ready to prepend to a command line. Values are shell-quoted. */
|
|
29
|
+
function envPrefix(env) {
|
|
30
|
+
return `env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Given the env a route could not carry and the command (if any) that would run in the opened pane,
|
|
34
|
+
* decide how env rides in. With a command, env is prefixed onto it and the pane carries the value;
|
|
35
|
+
* with none, env is dropped and the caller warns. No env (or an empty map) is `carried` unchanged, so
|
|
36
|
+
* a caller on the losing route can call this unconditionally and get the right command back.
|
|
37
|
+
*/
|
|
38
|
+
function envFallback(env, command) {
|
|
39
|
+
if (env === void 0 || Object.keys(env).length === 0) return {
|
|
40
|
+
kind: "carried",
|
|
41
|
+
command
|
|
42
|
+
};
|
|
43
|
+
if (command === void 0) return {
|
|
44
|
+
kind: "dropped",
|
|
45
|
+
variables: Object.keys(env)
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
kind: "carried",
|
|
49
|
+
command: `${envPrefix(env)}${command}`
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/floating.ts
|
|
54
|
+
/**
|
|
55
|
+
* The floating-pane refusal — the `'pane:float'` placement's answer on a backend that has no
|
|
56
|
+
* floating-pane concept (wezterm, herdr).
|
|
57
|
+
*
|
|
58
|
+
* Its own module rather than a member of `mux.ts` for the reason every other seam type is not a
|
|
59
|
+
* class: `mux.ts` is the CONTRACT and carries no runtime value, so putting the one class the
|
|
60
|
+
* contract's refusal needs there would make every consumer of the types import a value too. It is
|
|
61
|
+
* the core-surface parallel of `CaptureUnsupportedError` (`template-capture.ts`) and
|
|
62
|
+
* `AgentLifecycleUnsupportedError` (`agent.ts`), and it rides the `.` barrel rather than a subpath
|
|
63
|
+
* because the verb it refuses — `open` — is on the surface everybody gets.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* A floating pane asked of a backend that cannot open one (`open` with `at: 'pane:float'` on wezterm
|
|
67
|
+
* or herdr). A refusal, never a substitution: the nearest thing those backends could open is a tiled
|
|
68
|
+
* split, which takes a share of the region and resizes its other panes — exactly the property a float
|
|
69
|
+
* exists to avoid — so a caller would get back a pane whose id satisfies them and whose behavior does
|
|
70
|
+
* not. There is no truthful degrade, so there is no degrade.
|
|
71
|
+
*
|
|
72
|
+
* PORTABLE and exit-code-free by design, the exact mirror of `AgentLifecycleUnsupportedError`. The
|
|
73
|
+
* DECISION to refuse is the library's, made inside each adapter's `open` — the one place that sees
|
|
74
|
+
* both the backend and the requested placement. How the refusal SURFACES (the exit code, the fix
|
|
75
|
+
* hint, the exact sentence) is the CLI's, which catches this and re-raises its own
|
|
76
|
+
* `backend-unsupported` error. `backend` names the backend so a caller composes the message without
|
|
77
|
+
* re-deriving it; the terse `message` is a factual log line.
|
|
78
|
+
*/
|
|
79
|
+
var FloatingPanesUnsupportedError = class extends Error {
|
|
80
|
+
backend;
|
|
81
|
+
constructor(backend) {
|
|
82
|
+
super(`${backend} cannot open a floating pane`);
|
|
83
|
+
this.backend = backend;
|
|
84
|
+
this.name = "FloatingPanesUnsupportedError";
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Refuse a `'pane:float'` open on the backend named — the single spelling of the refusal, called by
|
|
89
|
+
* every adapter that lacks the capability so the two cannot drift into two different messages.
|
|
90
|
+
*
|
|
91
|
+
* Takes the NAME rather than the adapter: it is called from inside `open`, where the adapter object is
|
|
92
|
+
* still being constructed on some backends, and the name is the only thing the error carries anyway.
|
|
93
|
+
*/
|
|
94
|
+
function refuseFloatingPane(backend) {
|
|
95
|
+
throw new FloatingPanesUnsupportedError(backend);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether `adapter` can open a floating pane — the declaration read, so a caller asking the question
|
|
99
|
+
* before opening spells it once rather than reaching into an optional member that may be `undefined`.
|
|
100
|
+
*
|
|
101
|
+
* The pre-flight check, not the enforcement: `open` re-checks as its own contract (the same
|
|
102
|
+
* belt-and-braces `agent wait` runs against `agentLifecycle`), so a caller that skips this is refused
|
|
103
|
+
* just as loudly — one exec later.
|
|
104
|
+
*/
|
|
105
|
+
function canFloatPanes(adapter) {
|
|
106
|
+
return adapter.canFloatPanes === true;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/wait-output.ts
|
|
110
|
+
/** How long a polling backend sleeps between reads when the caller names no cadence. */
|
|
111
|
+
const DEFAULT_WAIT_POLL_MS = 150;
|
|
112
|
+
/**
|
|
113
|
+
* The seam's own precondition on a wait pattern: EXACTLY ONE of `match`/`regex`, and a `regex` that
|
|
114
|
+
* compiles.
|
|
115
|
+
*
|
|
116
|
+
* Enforced here, at the seam, rather than per adapter, for `assertRatioInRange`'s reason — it is a
|
|
117
|
+
* universal property of what a wait pattern IS, true on every backend, not a per-backend policy. Both
|
|
118
|
+
* halves matter for portability in different ways: the one-of rule is refusable by herdr's CLI and by
|
|
119
|
+
* nothing at all on a polling backend, so leaving it to the backend would make the same call fail on
|
|
120
|
+
* one and silently pick a winner on another; and compiling the source turns a MALFORMED pattern into
|
|
121
|
+
* the same loud failure everywhere, instead of a herdr refusal on one backend and a poll that throws
|
|
122
|
+
* on its first read somewhere else.
|
|
123
|
+
*
|
|
124
|
+
* What it deliberately does NOT check is dialect: a pattern using ECMAScript-only syntax compiles here
|
|
125
|
+
* and is then herdr's own to accept or refuse (see `MuxWaitOptions.regex`). Validating against the
|
|
126
|
+
* intersection of two regex engines would mean shipping a third one.
|
|
127
|
+
*/
|
|
128
|
+
function assertWaitPattern(opts) {
|
|
129
|
+
const hasMatch = opts.match != null;
|
|
130
|
+
const hasRegex = opts.regex != null;
|
|
131
|
+
if (hasMatch && hasRegex) throw new Error("wait pattern must be one of match or regex — got both");
|
|
132
|
+
if (!hasMatch && !hasRegex) throw new Error("wait pattern must be one of match or regex — got neither");
|
|
133
|
+
if (opts.match != null && opts.match === "") throw new Error("wait pattern match must not be empty");
|
|
134
|
+
if (opts.regex != null) try {
|
|
135
|
+
new RegExp(opts.regex);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw new Error(`wait pattern regex is not a valid expression: ${opts.regex} — ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether `output` satisfies the pattern, and the single line to point at when it does.
|
|
142
|
+
*
|
|
143
|
+
* The match runs against the WHOLE snapshot, not line by line, so a regex that spans a newline still
|
|
144
|
+
* hits — that is why `matchedLine` is derived separately and left absent when no single line carries
|
|
145
|
+
* the match on its own. Pure, so the tricky half is testable with no multiplexer at all, exactly as
|
|
146
|
+
* `template-capture`'s geometry derivation is.
|
|
147
|
+
*/
|
|
148
|
+
function matchWaitPattern(output, opts) {
|
|
149
|
+
assertWaitPattern(opts);
|
|
150
|
+
const hit = (text) => opts.match != null ? text.includes(opts.match) : new RegExp(opts.regex).test(text);
|
|
151
|
+
if (!hit(output)) return {
|
|
152
|
+
matched: false,
|
|
153
|
+
output
|
|
154
|
+
};
|
|
155
|
+
const line = output.split("\n").find(hit);
|
|
156
|
+
return {
|
|
157
|
+
matched: true,
|
|
158
|
+
output,
|
|
159
|
+
...line != null ? { matchedLine: line } : {}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* `waitForOutput` for a backend with NO native wait — poll its own `read` until the pattern matches or
|
|
164
|
+
* the deadline passes. tmux, WezTerm and Zellij all route their seam method straight through here, so
|
|
165
|
+
* the three share one cadence, one deadline rule and one liveness rule rather than three copies that
|
|
166
|
+
* can drift; herdr overrides it with its native primitive.
|
|
167
|
+
*
|
|
168
|
+
* **Reads first, sleeps second.** The snapshot on screen when the call arrives is searched before any
|
|
169
|
+
* sleeping, so a pattern already printed returns immediately — the seam's stated "existing output
|
|
170
|
+
* counts" rule, and the same order herdr's native wait documents for itself.
|
|
171
|
+
*
|
|
172
|
+
* **A gone pane throws instead of timing out**, which is `nudge`'s rule for the same reason: a dead
|
|
173
|
+
* pane and a quiet one both read back empty, so without the liveness probe every dead peer would be
|
|
174
|
+
* reported as a timeout — a shape the caller reads as "still working" — and the real cause would be
|
|
175
|
+
* buried. Probed BEFORE the first read (so a pane that was already gone fails at once rather than
|
|
176
|
+
* after the full timeout) and again after the deadline (so a pane that died mid-wait is not reported as
|
|
177
|
+
* one that merely stayed quiet). Never probed per poll: that would double every backend's query load
|
|
178
|
+
* for a fact that only changes the verdict at the end.
|
|
179
|
+
*/
|
|
180
|
+
async function pollForOutput(adapter, exec, target, opts) {
|
|
181
|
+
assertWaitPattern(opts);
|
|
182
|
+
const pollMs = opts.pollMs ?? 150;
|
|
183
|
+
const now = opts.now ?? (() => Date.now());
|
|
184
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
185
|
+
const readOpts = opts.lines != null ? { lines: opts.lines } : void 0;
|
|
186
|
+
assertPaneLive(adapter, exec, target);
|
|
187
|
+
const deadline = now() + opts.timeoutMs;
|
|
188
|
+
let output = "";
|
|
189
|
+
for (;;) {
|
|
190
|
+
output = adapter.read(exec, target, readOpts).text;
|
|
191
|
+
const result = matchWaitPattern(output, opts);
|
|
192
|
+
if (result.matched) return result;
|
|
193
|
+
if (now() >= deadline) break;
|
|
194
|
+
await sleep(pollMs);
|
|
195
|
+
}
|
|
196
|
+
assertPaneLive(adapter, exec, target);
|
|
197
|
+
return {
|
|
198
|
+
matched: false,
|
|
199
|
+
output
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** The liveness probe both ends of a poll share, throwing `nudge`'s named failure rather than letting a
|
|
203
|
+
* dead pane be reported as a quiet one. */
|
|
204
|
+
function assertPaneLive(adapter, exec, target) {
|
|
205
|
+
if (!adapter.paneExists(exec, target)) throw new Error(`wait failed: pane ${target.id} no longer exists — the pane is gone, not quiet.`);
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/mux.cmux.ts
|
|
209
|
+
/**
|
|
210
|
+
* cmux backend — detected via `$CMUX_WORKSPACE_ID`. Drives cmux's CLI through `cmux <verb> …`
|
|
211
|
+
* (https://cmux.com/docs/api), the same synchronous-CLI shape tmux, herdr, wezterm, and zellij
|
|
212
|
+
* already give `Exec`.
|
|
213
|
+
*
|
|
214
|
+
* cmux is a Ghostty-based macOS terminal with vertical tabs and notifications for AI coding agents.
|
|
215
|
+
* Its hierarchy is Window → Workspace → Pane → Surface. A **Surface** is the terminal unit (a tab
|
|
216
|
+
* within a pane), which maps to cyber-mux's `LivePane`. The env variable `$CMUX_SURFACE_ID` carries
|
|
217
|
+
* the caller's surface identity — analogous to `$TMUX_PANE` or `$WEZTERM_PANE`.
|
|
218
|
+
*
|
|
219
|
+
* Probed from the cmux docs and CLI reference only — cmux is not installed in this sandbox (it is
|
|
220
|
+
* macOS-GUI-only), so nothing here carries the "verified against a live binary" claim
|
|
221
|
+
* `mux.tmux.ts`/`mux.herdr.ts` make; it makes the same honest disclaimer `mux.wezterm.ts` and
|
|
222
|
+
* `mux.zellij.ts` do.
|
|
223
|
+
*
|
|
224
|
+
* Real capability shape that fell out of the probe:
|
|
225
|
+
*
|
|
226
|
+
* - **Surface is the terminal unit, not Pane.** cmux's "pane" holds multiple "surfaces" (tabs). The
|
|
227
|
+
* terminal a command runs in is a surface, so `CMUX_SURFACE_ID` is the self-identity env var and
|
|
228
|
+
* `LivePane.id` carries a surface id. `--at tab` maps to `cmux new-surface` (a new tab in the
|
|
229
|
+
* current pane); `--at pane:*` maps to `cmux new-pane` (a split, which creates a pane with one
|
|
230
|
+
* surface).
|
|
231
|
+
* - **Workspace is a real tier.** `cmux new-workspace` creates a genuinely separate workspace,
|
|
232
|
+
* reported as `OpenedPane.workspace`.
|
|
233
|
+
* - **No `--env` on any route.** Like wezterm and zellij, env is native at no tier, so every open
|
|
234
|
+
* rides the `envFallback` compensation (an `env K=V` prefix on the launch command, or a stderr
|
|
235
|
+
* warning when there is no command to ride).
|
|
236
|
+
* - **Splits can be sized** — `cmux new-pane --direction right --size 0.3` sizes the NEW pane, so
|
|
237
|
+
* `ratio` (fraction kept by the ORIGINAL) is inverted to `1 - ratio`. `canSizeSplits` is true.
|
|
238
|
+
* - **`new-pane` has no split-TARGET flag.** It splits the focused pane (or the biggest space); the
|
|
239
|
+
* `--workspace` flag specifies which workspace, but not which pane within it. So `from` — which
|
|
240
|
+
* pane a `pane:*` split lands beside — is honored by FOCUSING that surface first, the sole way to
|
|
241
|
+
* choose the split target. That is a real focus move, and the honest cost of getting the RIGHT
|
|
242
|
+
* pane split.
|
|
243
|
+
* - **No pane geometry adapter.** `cmux list-panes --json` does not report position, so `regions`
|
|
244
|
+
* (`describeRegion`/`describeWorkspace`) is not implementable. `template save` refuses on cmux by
|
|
245
|
+
* naming the backend, the same optional-absence it handles for wezterm.
|
|
246
|
+
* - **No git-worktree concept in the CLI.** No `worktree` subcommand, so — like tmux, wezterm, and
|
|
247
|
+
* zellij — this backend never binds a worktree to a workspace; callers fall back to plain git plus
|
|
248
|
+
* `open()`.
|
|
249
|
+
* - **Naming surfaces.** cmux surfaces can be labeled — verified against the skill docs: a label can
|
|
250
|
+
* be set after creation. No `--label` flag on `new-surface` / `new-pane`, so naming is post-birth.
|
|
251
|
+
*/
|
|
252
|
+
function createCmuxAdapter(deps) {
|
|
253
|
+
const adapter = {
|
|
254
|
+
name: "cmux",
|
|
255
|
+
canSizeSplits: true,
|
|
256
|
+
/**
|
|
257
|
+
* `false`, and cmux needs BOTH halves to be true to say otherwise. No creating verb documents a
|
|
258
|
+
* suppress-focus flag, so a new pane activates; and `new-pane` has no split-TARGET flag, so
|
|
259
|
+
* `from` is honored by focusing that surface first (see the header and `open` below) — a focus
|
|
260
|
+
* move made before the open even runs. Suppressing only one would still move the user, which
|
|
261
|
+
* the seam's own wording rules out.
|
|
262
|
+
*
|
|
263
|
+
* Read off cmux's documented CLI, NOT verified against a live binary — no cmux on the machine
|
|
264
|
+
* this was written on, matching the rest of this header.
|
|
265
|
+
*/
|
|
266
|
+
opensWithoutStealingFocus: false,
|
|
267
|
+
open(exec, opts) {
|
|
268
|
+
const at = opts.at ?? "tab";
|
|
269
|
+
if (at === "workspace") {
|
|
270
|
+
const args = ["--json", "new-workspace"];
|
|
271
|
+
if (opts.cwd) args.push("--cwd", opts.cwd);
|
|
272
|
+
const out = exec("cmux", args);
|
|
273
|
+
if (!out) throw new Error(withReason(exec, "cmux new-workspace failed"));
|
|
274
|
+
const parsed = parseCmuxOutput(out);
|
|
275
|
+
if (!parsed.workspace_ref) throw new Error("cmux new-workspace did not report the workspace ref");
|
|
276
|
+
const surfaceId = parsed.surface_ref;
|
|
277
|
+
if (!surfaceId) throw new Error("cmux new-workspace did not report the initial surface ref");
|
|
278
|
+
const opened = openedSurface(surfaceId, parsed.pane_ref, parsed.workspace_ref);
|
|
279
|
+
if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
|
|
280
|
+
runLaunch$3(adapter, exec, opened, opts.env, opts.launch);
|
|
281
|
+
return opened;
|
|
282
|
+
}
|
|
283
|
+
if (at === "tab") {
|
|
284
|
+
const args = ["--json", "new-surface"];
|
|
285
|
+
if (opts.within) args.push("--pane", opts.within);
|
|
286
|
+
if (opts.cwd) args.push("--cwd", opts.cwd);
|
|
287
|
+
const out = exec("cmux", args);
|
|
288
|
+
if (!out) throw new Error(withReason(exec, "cmux new-surface failed"));
|
|
289
|
+
const parsed = parseCmuxOutput(out);
|
|
290
|
+
const surfaceId = parsed.surface_ref;
|
|
291
|
+
if (!surfaceId) throw new Error("cmux new-surface did not report the surface ref");
|
|
292
|
+
const opened = openedSurface(surfaceId, parsed.pane_ref, deps.workspace);
|
|
293
|
+
if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
|
|
294
|
+
runLaunch$3(adapter, exec, opened, opts.env, opts.launch);
|
|
295
|
+
return opened;
|
|
296
|
+
}
|
|
297
|
+
if (at === "pane:float") refuseFloatingPane(adapter.name);
|
|
298
|
+
if (opts.from) adapter.focus(exec, opts.from);
|
|
299
|
+
const args = [
|
|
300
|
+
"--json",
|
|
301
|
+
"new-pane",
|
|
302
|
+
"--direction",
|
|
303
|
+
at === "pane:down" ? "down" : "right"
|
|
304
|
+
];
|
|
305
|
+
if (opts.cwd) args.push("--cwd", opts.cwd);
|
|
306
|
+
if (opts.ratio != null) args.push("--size", String(1 - opts.ratio));
|
|
307
|
+
const out = exec("cmux", args);
|
|
308
|
+
if (!out) throw new Error(withReason(exec, "cmux new-pane failed"));
|
|
309
|
+
const parsed = parseCmuxOutput(out);
|
|
310
|
+
const surfaceId = parsed.surface_ref;
|
|
311
|
+
if (!surfaceId) throw new Error("cmux new-pane did not report the surface ref");
|
|
312
|
+
const opened = openedSurface(surfaceId, parsed.pane_ref, deps.workspace);
|
|
313
|
+
if (opts.label) adapter.rename(exec, opened, "pane", opts.label);
|
|
314
|
+
runLaunch$3(adapter, exec, opened, opts.env, opts.launch);
|
|
315
|
+
return opened;
|
|
316
|
+
},
|
|
317
|
+
rename(exec, target, tier, name) {
|
|
318
|
+
if (tier === "tab") {
|
|
319
|
+
exec("cmux", [
|
|
320
|
+
"rename-surface",
|
|
321
|
+
"--surface",
|
|
322
|
+
target.id,
|
|
323
|
+
"--title",
|
|
324
|
+
name
|
|
325
|
+
]);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const paneRef = surfaceToPane(exec, target.id);
|
|
329
|
+
if (paneRef) exec("cmux", [
|
|
330
|
+
"rename-pane",
|
|
331
|
+
"--pane",
|
|
332
|
+
paneRef,
|
|
333
|
+
"--title",
|
|
334
|
+
name
|
|
335
|
+
]);
|
|
336
|
+
},
|
|
337
|
+
group() {},
|
|
338
|
+
sendText(exec, target, text) {
|
|
339
|
+
exec("cmux", [
|
|
340
|
+
"send",
|
|
341
|
+
"--surface",
|
|
342
|
+
target.id,
|
|
343
|
+
text
|
|
344
|
+
]);
|
|
345
|
+
},
|
|
346
|
+
sendKeys(exec, target, keys) {
|
|
347
|
+
for (const key of keys) exec("cmux", [
|
|
348
|
+
"send-key",
|
|
349
|
+
"--surface",
|
|
350
|
+
target.id,
|
|
351
|
+
toCmuxKey(key)
|
|
352
|
+
]);
|
|
353
|
+
},
|
|
354
|
+
submit(exec, target, text) {
|
|
355
|
+
if (!text) {
|
|
356
|
+
adapter.sendKeys(exec, target, ["Enter"]);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
adapter.sendText(exec, target, text);
|
|
360
|
+
adapter.sendKeys(exec, target, ["Enter"]);
|
|
361
|
+
},
|
|
362
|
+
read(exec, target, opts) {
|
|
363
|
+
const args = [
|
|
364
|
+
"read-screen",
|
|
365
|
+
"--surface",
|
|
366
|
+
target.id
|
|
367
|
+
];
|
|
368
|
+
if (opts?.lines != null) args.push("--lines", String(opts.lines));
|
|
369
|
+
return { text: exec("cmux", args) ?? "" };
|
|
370
|
+
},
|
|
371
|
+
waitForOutput(exec, target, opts) {
|
|
372
|
+
return pollForOutput(adapter, exec, target, opts);
|
|
373
|
+
},
|
|
374
|
+
focus(exec, target) {
|
|
375
|
+
exec("cmux", [
|
|
376
|
+
"focus-panel",
|
|
377
|
+
"--panel",
|
|
378
|
+
target.id
|
|
379
|
+
]);
|
|
380
|
+
},
|
|
381
|
+
teardown(exec, target) {
|
|
382
|
+
exec("cmux", [
|
|
383
|
+
"close-surface",
|
|
384
|
+
"--surface",
|
|
385
|
+
target.id
|
|
386
|
+
]);
|
|
387
|
+
},
|
|
388
|
+
paneExists(exec, target) {
|
|
389
|
+
return listCmuxSurfaces(exec).some((s) => s.id === target.id);
|
|
390
|
+
},
|
|
391
|
+
isPaneFocused(exec, target) {
|
|
392
|
+
const found = listCmuxSurfaces(exec).find((s) => s.id === target.id);
|
|
393
|
+
if (!found) return void 0;
|
|
394
|
+
return found.is_focused === true;
|
|
395
|
+
},
|
|
396
|
+
listPanes(exec) {
|
|
397
|
+
return listCmuxSurfaces(exec).map((s) => {
|
|
398
|
+
const pane = {
|
|
399
|
+
id: s.id,
|
|
400
|
+
mux: "cmux",
|
|
401
|
+
floating: false
|
|
402
|
+
};
|
|
403
|
+
if (s.cwd) pane.cwd = s.cwd;
|
|
404
|
+
if (s.title) pane.label = s.title;
|
|
405
|
+
return pane;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
return adapter;
|
|
410
|
+
}
|
|
411
|
+
function parseCmuxOutput(out) {
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(out);
|
|
414
|
+
} catch {
|
|
415
|
+
return {};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function listCmuxSurfaces(exec) {
|
|
419
|
+
const out = exec("cmux", ["list-panes", "--json"]);
|
|
420
|
+
if (!out) return [];
|
|
421
|
+
let parsed;
|
|
422
|
+
try {
|
|
423
|
+
parsed = JSON.parse(out);
|
|
424
|
+
} catch {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
if (!Array.isArray(parsed)) return [];
|
|
428
|
+
const surfaces = [];
|
|
429
|
+
for (const pane of parsed) if (pane && Array.isArray(pane.surfaces)) {
|
|
430
|
+
for (const s of pane.surfaces) if (s?.surface_ref) surfaces.push({
|
|
431
|
+
id: s.surface_ref,
|
|
432
|
+
title: s.title,
|
|
433
|
+
cwd: s.cwd,
|
|
434
|
+
is_focused: s.is_focused
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return surfaces;
|
|
438
|
+
}
|
|
439
|
+
function openedSurface(surfaceId, paneRef, workspace) {
|
|
440
|
+
const opened = {
|
|
441
|
+
id: surfaceId,
|
|
442
|
+
tab: paneRef ?? surfaceId
|
|
443
|
+
};
|
|
444
|
+
if (workspace) opened.workspace = workspace;
|
|
445
|
+
return opened;
|
|
446
|
+
}
|
|
447
|
+
function surfaceToPane(exec, surfaceId) {
|
|
448
|
+
const out = exec("cmux", ["list-panes", "--json"]);
|
|
449
|
+
if (!out) return void 0;
|
|
450
|
+
let parsed;
|
|
451
|
+
try {
|
|
452
|
+
parsed = JSON.parse(out);
|
|
453
|
+
} catch {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
457
|
+
for (const pane of parsed) if (pane?.pane_ref && Array.isArray(pane.surfaces)) {
|
|
458
|
+
for (const s of pane.surfaces) if (s && s.surface_ref === surfaceId) return pane.pane_ref;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function runLaunch$3(adapter, exec, target, env, launch) {
|
|
462
|
+
const fallback = envFallback(env, launch);
|
|
463
|
+
if (fallback.kind === "dropped") {
|
|
464
|
+
process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this cmux surface — cmux has no --env flag on new-pane/new-surface/new-workspace
|
|
465
|
+
`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The core key vocabulary's cmux spelling. cmux uses lowercase key names: enter, tab, escape, etc.
|
|
472
|
+
*/
|
|
473
|
+
const CMUX_KEY_RENAMES = {
|
|
474
|
+
Enter: "enter",
|
|
475
|
+
Tab: "tab",
|
|
476
|
+
Escape: "escape",
|
|
477
|
+
Backspace: "backspace",
|
|
478
|
+
Space: "space",
|
|
479
|
+
Up: "up",
|
|
480
|
+
Down: "down",
|
|
481
|
+
Left: "left",
|
|
482
|
+
Right: "right",
|
|
483
|
+
"C-c": "ctrl+c"
|
|
484
|
+
};
|
|
485
|
+
function toCmuxKey(key) {
|
|
486
|
+
return CMUX_KEY_RENAMES[key] ?? key.toLowerCase();
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/ratio.ts
|
|
490
|
+
/**
|
|
491
|
+
* The seam's own precondition on `MuxOpenOptions.ratio`: a fraction STRICTLY between 0 and 1.
|
|
492
|
+
*
|
|
493
|
+
* `ratio` is the fraction kept by the ORIGINAL pane. Outside `0 < ratio < 1` there is no split it can
|
|
494
|
+
* name: `1 - ratio` goes negative above 1 (tmux `-l -50%` / wezterm `--percent -50`), and 0 or 1 hands
|
|
495
|
+
* one side the whole region and the other nothing — a mistake, never an intent worth honoring. Left
|
|
496
|
+
* unrendered these produce a silently broken split, not an error, which is the exact silent-wrong
|
|
497
|
+
* output this seam's loud-over-quiet preference exists to refuse.
|
|
498
|
+
*
|
|
499
|
+
* Enforced HERE, at the seam, rather than left to each caller, because the invariant is a universal
|
|
500
|
+
* property of what a ratio IS — true on every backend — not a per-caller policy. (The DEGRADE policy —
|
|
501
|
+
* what a caller does when a backend cannot size a split at all — genuinely stays the caller's, unchanged;
|
|
502
|
+
* range validity and degrade policy are different questions.) A caller cannot reach an adapter with an
|
|
503
|
+
* out-of-range ratio and have it silently rendered; `template`'s schema still refuses one earlier, per
|
|
504
|
+
* node, with a path-qualified message, so the two layers do different jobs and the seam is the backstop.
|
|
505
|
+
*
|
|
506
|
+
* The guard lives WITH the rendering: it is called by each backend's size render helper, so a backend
|
|
507
|
+
* that cannot size a split (zellij) renders no ratio and so never reaches this guard — a dropped value
|
|
508
|
+
* is never checked, valid or not, which is the same as the even-default degrade its callers already take.
|
|
509
|
+
*/
|
|
510
|
+
function assertRatioInRange(ratio) {
|
|
511
|
+
if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) throw new Error(`ratio must be strictly between 0 and 1 — got ${ratio}`);
|
|
512
|
+
}
|
|
513
|
+
//#endregion
|
|
514
|
+
//#region src/read-window.ts
|
|
515
|
+
/**
|
|
516
|
+
* The READ WINDOW: how much of a pane a capture asks for, and whether rows sat above what came back.
|
|
517
|
+
*
|
|
518
|
+
* Both halves live here because they are one question. A capture is bounded — by the caller's `lines`,
|
|
519
|
+
* or by the backend's own default (the viewport on all four) — and "was anything dropped" is a
|
|
520
|
+
* property of that bound. Unbind the window (`lines: 'all'`) and the answer is `false` by
|
|
521
|
+
* construction, with no probe to spend.
|
|
522
|
+
*
|
|
523
|
+
* The truncation rule itself is shared by every adapter so the four backends answer
|
|
524
|
+
* `MuxReadResult.truncated` by one definition rather than four that can drift — the same reason
|
|
525
|
+
* `pollForOutput` owns one poll cadence for the three polling backends.
|
|
526
|
+
*
|
|
527
|
+
* Pure, and deliberately so: the tricky half of the answer is a row count, testable with no
|
|
528
|
+
* multiplexer at all (`template-capture`'s geometry derivation is the precedent). Each adapter owns
|
|
529
|
+
* only the one thing that genuinely differs — how its backend spells "one row deeper".
|
|
530
|
+
*/
|
|
531
|
+
/**
|
|
532
|
+
* How many terminal ROWS a capture carries.
|
|
533
|
+
*
|
|
534
|
+
* A trailing newline is a terminator, not an empty row: `capture-pane` and `dump-screen` both end
|
|
535
|
+
* their output with one, so counting it would make every capture look one row longer than the screen
|
|
536
|
+
* and — worse — would compare unequal against a probe that happened not to end with one. An empty
|
|
537
|
+
* capture is zero rows, not one.
|
|
538
|
+
*/
|
|
539
|
+
function capturedRows(text) {
|
|
540
|
+
if (text === "") return 0;
|
|
541
|
+
return (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n").length;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Whether `capture` omitted older rows, judged against `deeper` — the SAME read taken one row further
|
|
545
|
+
* back.
|
|
546
|
+
*
|
|
547
|
+
* More rows in the deeper read means there was output above the captured window that the caller did
|
|
548
|
+
* not receive. An equal (or smaller) count means the deeper read had nothing more to give: the
|
|
549
|
+
* backend clamped at the top of what it holds, so the caller has everything there is.
|
|
550
|
+
*
|
|
551
|
+
* Row counts, not text equality, because the two reads are not required to render the shared rows
|
|
552
|
+
* identically — herdr's deeper probe reads a different `--source`, and a backend may re-wrap. What
|
|
553
|
+
* both reads DO agree on is how many rows they returned, and that is the whole question.
|
|
554
|
+
*/
|
|
555
|
+
function isReadTruncated(capture, deeper) {
|
|
556
|
+
return capturedRows(deeper) > capturedRows(capture);
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* The row count that stands in for "the whole scrollback" on a backend whose read takes a NUMBER and
|
|
560
|
+
* has no all-history token of its own (WezTerm's `--start-line`, herdr's `--lines`) — tmux (`-S -`)
|
|
561
|
+
* and Zellij (`--full`) say it exactly and never reach for this.
|
|
562
|
+
*
|
|
563
|
+
* A million rows is past any real pane's history (tmux's own `history-limit` defaults to 2000) and
|
|
564
|
+
* both backends CLAMP an over-deep window to what they hold rather than failing, so this reads as
|
|
565
|
+
* "everything" without pretending to be a precise number. It stays under a u32, which is what herdr's
|
|
566
|
+
* CLI parses `--lines` as.
|
|
567
|
+
*/
|
|
568
|
+
const FULL_SCROLLBACK_LINES = 1e6;
|
|
569
|
+
//#endregion
|
|
570
|
+
//#region src/region-tree.ts
|
|
571
|
+
const right = (rect) => rect.x + rect.width;
|
|
572
|
+
const bottom = (rect) => rect.y + rect.height;
|
|
573
|
+
const HORIZONTAL = {
|
|
574
|
+
direction: "right",
|
|
575
|
+
start: (r) => r.x,
|
|
576
|
+
end: right
|
|
577
|
+
};
|
|
578
|
+
const VERTICAL = {
|
|
579
|
+
direction: "down",
|
|
580
|
+
start: (r) => r.y,
|
|
581
|
+
end: bottom
|
|
582
|
+
};
|
|
583
|
+
/**
|
|
584
|
+
* The lowest cut on this axis that separates the panes cleanly, or `undefined` if none does.
|
|
585
|
+
*
|
|
586
|
+
* Taking the LOWEST rather than any is what produces a right-comb for an n-ary row: three panes side
|
|
587
|
+
* by side cut first into `[a][b c]`, then `[b][c]` — the exact tree `desugar`'s `comb` emits for
|
|
588
|
+
* `arrange: even-horizontal`, reached from the opposite direction.
|
|
589
|
+
*
|
|
590
|
+
* A candidate is any pane's start edge. It separates cleanly when every pane lies wholly before it
|
|
591
|
+
* or wholly after it, and both sides have something in them.
|
|
592
|
+
*/
|
|
593
|
+
function findCut(panes, axis) {
|
|
594
|
+
const candidates = [...new Set(panes.map((p) => axis.start(p.rect)))].sort((a, b) => a - b);
|
|
595
|
+
for (const at of candidates) {
|
|
596
|
+
const first = panes.filter((p) => axis.end(p.rect) <= at);
|
|
597
|
+
const second = panes.filter((p) => axis.start(p.rect) >= at);
|
|
598
|
+
if (first.length === 0 || second.length === 0) continue;
|
|
599
|
+
if (first.length + second.length !== panes.length) continue;
|
|
600
|
+
return {
|
|
601
|
+
direction: axis.direction,
|
|
602
|
+
ratio: ratioOf(panes, second, axis),
|
|
603
|
+
first,
|
|
604
|
+
second
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The fraction of the split region kept by `first` — the schema's `ratio`.
|
|
610
|
+
*
|
|
611
|
+
* Measured as the COMPLEMENT of what `second` occupies, over the whole region: `1 - second/total`.
|
|
612
|
+
* The obvious `first / (first + second)` is subtly wrong on any backend that draws a divider, and
|
|
613
|
+
* the arithmetic says why — tmux splitting a 50-row region reports 34 + 15, with the 51st row eaten
|
|
614
|
+
* by the divider. `first / (first + second)` reads 34/49 = 0.69; the true split was 0.7, and the
|
|
615
|
+
* divider row belongs to neither pane's height while still costing the region a row.
|
|
616
|
+
*
|
|
617
|
+
* Taking the complement puts that row back where the backend's own arithmetic puts it: tmux's `-l`
|
|
618
|
+
* sizes the NEW pane, so `second` is exactly the fraction asked for and `first` keeps the rest,
|
|
619
|
+
* divider included. That reads 1 - 15/50 = 0.7 — the number the split was actually made with. On a
|
|
620
|
+
* backend with no divider (herdr) the two formulas agree, so nothing is traded for the fix.
|
|
621
|
+
*
|
|
622
|
+
* Both checked against live binaries: this recovers tmux's `-l 40%`/`-l 30%` splits as 0.6/0.7
|
|
623
|
+
* exactly, and reproduces herdr's to within the cell it rounds to.
|
|
624
|
+
*/
|
|
625
|
+
function ratioOf(all, second, axis) {
|
|
626
|
+
const total = extent(all, axis);
|
|
627
|
+
if (total <= 0) return .5;
|
|
628
|
+
return 1 - extent(second, axis) / total;
|
|
629
|
+
}
|
|
630
|
+
/** How far a group of panes reaches along an axis — its bounding box on that axis. */
|
|
631
|
+
function extent(panes, axis) {
|
|
632
|
+
const starts = panes.map((p) => axis.start(p.rect));
|
|
633
|
+
const ends = panes.map((p) => axis.end(p.rect));
|
|
634
|
+
return Math.max(...ends) - Math.min(...starts);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Cut the region into a binary tree, recursively.
|
|
638
|
+
*
|
|
639
|
+
* **`right` is tried before `down`, and the order is load-bearing on a grid.** A 2x2 is genuinely
|
|
640
|
+
* ambiguous — cutting it vertically first and horizontally first both describe the same screen, and
|
|
641
|
+
* neither is more true. Columns-then-rows is the tie-break because that is what `desugar`'s `tiled`
|
|
642
|
+
* emits, so a tiled pool exports back as the tree it was built from rather than its transpose.
|
|
643
|
+
*
|
|
644
|
+
* A region no cut separates cannot come out of a multiplexer: both backends build regions BY
|
|
645
|
+
* splitting, so every region they can report is guillotine-cuttable by construction. Reaching the
|
|
646
|
+
* throw means the geometry did not come from where we think it did — which is worth saying loudly
|
|
647
|
+
* rather than papering over with a tree that misplaces the user's panes.
|
|
648
|
+
*/
|
|
649
|
+
function partition(panes) {
|
|
650
|
+
if (panes.length === 1) return {
|
|
651
|
+
type: "pane",
|
|
652
|
+
pane: panes[0]
|
|
653
|
+
};
|
|
654
|
+
const cut = findCut(panes, HORIZONTAL) ?? findCut(panes, VERTICAL);
|
|
655
|
+
if (!cut) throw new Error(`this region's panes do not form a splittable tree (${panes.length} panes: ${panes.map((p) => p.id).join(", ")}) — export can only capture a region built by splitting`);
|
|
656
|
+
return {
|
|
657
|
+
type: "split",
|
|
658
|
+
direction: cut.direction,
|
|
659
|
+
ratio: cut.ratio,
|
|
660
|
+
first: partition(cut.first),
|
|
661
|
+
second: partition(cut.second)
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The split a pane sits DIRECTLY inside, with the numbers a resize needs — or `undefined` when the
|
|
666
|
+
* region has no split at all (a single-pane region: nothing to take a fraction of).
|
|
667
|
+
*
|
|
668
|
+
* **The target is always one WHOLE side of the split it encloses.** That falls out of the guillotine
|
|
669
|
+
* derivation rather than being asserted: a leaf's parent is the last cut before it, so whichever side
|
|
670
|
+
* of that cut the target lands on holds the target and nothing else. The OTHER side may be a group of
|
|
671
|
+
* panes, which is why it is reported as an extent rather than a pane — a backend sizes the target, and
|
|
672
|
+
* the group on the far side takes what is left.
|
|
673
|
+
*
|
|
674
|
+
* **Extents, not ratios alone, because the backends need different arithmetic.** tmux's
|
|
675
|
+
* `resize-pane -x/-y` takes CELLS, so it needs the split region's extent to turn a fraction into one;
|
|
676
|
+
* herdr's `pane resize --amount` takes a ratio DELTA, so it needs the fraction the split is at now.
|
|
677
|
+
* Reporting both raw facts keeps the per-backend conversion in the adapter that owns it — the same
|
|
678
|
+
* split `MuxOpenOptions.ratio` already makes, where the seam's number is one thing and each backend's
|
|
679
|
+
* rendering of it is another.
|
|
680
|
+
*
|
|
681
|
+
* **`extent` includes the divider a backend draws.** It is the split region's own span, so on tmux
|
|
682
|
+
* `targetExtent + otherExtent` falls one short of it and on herdr the two add up exactly. Neither
|
|
683
|
+
* adapter hard-codes which: the divider is `extent - targetExtent - otherExtent`, measured from the
|
|
684
|
+
* rects the backend just reported.
|
|
685
|
+
*/
|
|
686
|
+
function enclosingSplit(panes, paneId) {
|
|
687
|
+
if (!panes.some((p) => p.id === paneId)) throw new Error(`pane ${paneId} is not in the region described (${panes.map((p) => p.id).join(", ")})`);
|
|
688
|
+
let node = partition(panes);
|
|
689
|
+
while (node.type === "split") {
|
|
690
|
+
const split = node;
|
|
691
|
+
const targetIsFirst = holds(split.first, paneId);
|
|
692
|
+
const side = targetIsFirst ? split.first : split.second;
|
|
693
|
+
const other = targetIsFirst ? split.second : split.first;
|
|
694
|
+
if (side.type !== "pane") {
|
|
695
|
+
node = side;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const axis = split.direction === "right" ? HORIZONTAL : VERTICAL;
|
|
699
|
+
return {
|
|
700
|
+
direction: split.direction,
|
|
701
|
+
targetIsFirst,
|
|
702
|
+
firstRatio: split.ratio,
|
|
703
|
+
extent: extent(leaves(split), axis),
|
|
704
|
+
targetExtent: extent([side.pane], axis),
|
|
705
|
+
otherExtent: extent(leaves(other), axis)
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Whether `paneId` is somewhere under `node`. */
|
|
710
|
+
function holds(node, paneId) {
|
|
711
|
+
return leaves(node).some((p) => p.id === paneId);
|
|
712
|
+
}
|
|
713
|
+
/** Every pane under `node`, in order. */
|
|
714
|
+
function leaves(node) {
|
|
715
|
+
return node.type === "pane" ? [node.pane] : [...leaves(node.first), ...leaves(node.second)];
|
|
716
|
+
}
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/mux.herdr.ts
|
|
719
|
+
/**
|
|
720
|
+
* herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
|
|
721
|
+
* multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
|
|
722
|
+
* only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
|
|
723
|
+
* rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
|
|
724
|
+
* convention exactly like the tmux adapter — no new client/transport needed.
|
|
725
|
+
*
|
|
726
|
+
* The pane lifecycle (split/run/read/close) is verified against a live herdr binary; `pane split`
|
|
727
|
+
* returns a JSON `pane_info` envelope whose id is extracted in `parsePaneId`.
|
|
728
|
+
*
|
|
729
|
+
* **Verified against 0.8.2** (protocol 20), re-probed against a live server by re-running
|
|
730
|
+
* `mux.herdr.integration.test.ts` — the real-boundary suite, not a hand check. Everything this
|
|
731
|
+
* adapter drives held: the split/read/run/send-keys lifecycle, `pane wait-output`'s success and
|
|
732
|
+
* error envelopes, `pane list`/`get`/`layout`, `workspace create`/`tab create`, and the `env` and
|
|
733
|
+
* worktree parameter sets below. The per-claim markers that follow name the version each was LAST
|
|
734
|
+
* established against — a claim still reading 0.7.4/0.7.5/0.8.0 is one a later release gave no
|
|
735
|
+
* occasion to re-measure (no attached client, or no live agent in the pane), not one that failed.
|
|
736
|
+
*
|
|
737
|
+
* What 0.8.2 did NOT re-establish, stated so the claim above is not read wider than it is: the two
|
|
738
|
+
* current-pane-context opens (`at:'tab'`, `at:'pane:right'`) skip when the suite runs INSIDE a herdr
|
|
739
|
+
* pane, and they are the only cases that exercise `--current`. 0.8.2 fixed `pane current`, `pane
|
|
740
|
+
* get`, and `pane layout --current` to resolve the CALLING pane rather than another client's focused
|
|
741
|
+
* pane (herdr #2297, #2298) — but that fix names none of `pane split`, which is where this file's one
|
|
742
|
+
* `--current` sits. So the 0.7.4 caveat on it below stands, unmeasured against 0.8.2 rather than
|
|
743
|
+
* re-confirmed.
|
|
744
|
+
*/
|
|
745
|
+
const herdrMuxAdapter = {
|
|
746
|
+
name: "herdr",
|
|
747
|
+
canSizeSplits: true,
|
|
748
|
+
/**
|
|
749
|
+
* Every creating verb this adapter issues carries `--no-focus`, so no open moves the user.
|
|
750
|
+
*
|
|
751
|
+
* `workspace create` and `tab create` have carried it from the start. `pane split` did NOT, and
|
|
752
|
+
* measuring it against a live 0.8.2 is what settled whether that was a hole: with the client
|
|
753
|
+
* focused on the pane being split, a bare `herdr pane split` left focus exactly where it was, and
|
|
754
|
+
* so did the same command with `--no-focus`. herdr's split simply does not activate what it
|
|
755
|
+
* creates. So the flag added there is a measured NO-OP today, and it is passed anyway — the seam
|
|
756
|
+
* now DECLARES this property, and a declaration backed by a backend default is one a future herdr
|
|
757
|
+
* release can falsify without anything here having to be wrong first. Every route saying what it
|
|
758
|
+
* wants is what makes the boolean above enforceable rather than incidental.
|
|
759
|
+
*
|
|
760
|
+
* No focus move to undo on top of that: `pane split <id>` names its target directly, so herdr
|
|
761
|
+
* never has to VISIT a pane to choose which one gets split.
|
|
762
|
+
*/
|
|
763
|
+
opensWithoutStealingFocus: true,
|
|
764
|
+
open(exec, opts) {
|
|
765
|
+
const at = opts.at ?? "tab";
|
|
766
|
+
const label = opts.label ? ["--label", opts.label] : [];
|
|
767
|
+
const env = envFlags(opts.env);
|
|
768
|
+
let opened;
|
|
769
|
+
if (at === "workspace") {
|
|
770
|
+
const out = exec("herdr", [
|
|
771
|
+
"workspace",
|
|
772
|
+
"create",
|
|
773
|
+
"--cwd",
|
|
774
|
+
opts.cwd,
|
|
775
|
+
...label,
|
|
776
|
+
...env,
|
|
777
|
+
"--no-focus"
|
|
778
|
+
]);
|
|
779
|
+
if (!out) throw new Error(withReason(exec, "herdr workspace create failed"));
|
|
780
|
+
opened = parseRootPaneId(out, "herdr workspace create");
|
|
781
|
+
} else if (at === "tab") {
|
|
782
|
+
const out = exec("herdr", [
|
|
783
|
+
"tab",
|
|
784
|
+
"create",
|
|
785
|
+
...opts.within ? ["--workspace", opts.within] : [],
|
|
786
|
+
"--cwd",
|
|
787
|
+
opts.cwd,
|
|
788
|
+
...label,
|
|
789
|
+
...env,
|
|
790
|
+
"--no-focus"
|
|
791
|
+
]);
|
|
792
|
+
if (!out) throw new Error(withReason(exec, "herdr tab create failed"));
|
|
793
|
+
opened = parseRootPaneId(out, "herdr tab create");
|
|
794
|
+
} else if (at === "pane:float") refuseFloatingPane(herdrMuxAdapter.name);
|
|
795
|
+
else {
|
|
796
|
+
const direction = at === "pane:down" ? "down" : "right";
|
|
797
|
+
const from = opts.from ? [opts.from.id] : ["--current"];
|
|
798
|
+
const size = opts.ratio != null ? ["--ratio", toHerdrRatio(opts.ratio)] : [];
|
|
799
|
+
const out = exec("herdr", [
|
|
800
|
+
"pane",
|
|
801
|
+
"split",
|
|
802
|
+
...from,
|
|
803
|
+
"--direction",
|
|
804
|
+
direction,
|
|
805
|
+
"--cwd",
|
|
806
|
+
opts.cwd,
|
|
807
|
+
...size,
|
|
808
|
+
...env,
|
|
809
|
+
"--no-focus"
|
|
810
|
+
]);
|
|
811
|
+
if (!out) throw new Error(withReason(exec, "herdr pane split failed"));
|
|
812
|
+
opened = parsePaneId(out);
|
|
813
|
+
if (opts.label) herdrMuxAdapter.rename(exec, opened, "pane", opts.label);
|
|
814
|
+
}
|
|
815
|
+
if (opts.launch) herdrMuxAdapter.submit(exec, opened, opts.launch);
|
|
816
|
+
return opened;
|
|
817
|
+
},
|
|
818
|
+
rename(exec, target, tier, name) {
|
|
819
|
+
exec("herdr", [
|
|
820
|
+
tier,
|
|
821
|
+
"rename",
|
|
822
|
+
target.id,
|
|
823
|
+
name
|
|
824
|
+
]);
|
|
825
|
+
},
|
|
826
|
+
group() {},
|
|
827
|
+
worktree: herdrWorktreeCapability(),
|
|
828
|
+
sendText(exec, target, text) {
|
|
829
|
+
exec("herdr", [
|
|
830
|
+
"pane",
|
|
831
|
+
"send-text",
|
|
832
|
+
target.id,
|
|
833
|
+
text
|
|
834
|
+
]);
|
|
835
|
+
},
|
|
836
|
+
sendKeys(exec, target, keys) {
|
|
837
|
+
exec("herdr", [
|
|
838
|
+
"pane",
|
|
839
|
+
"send-keys",
|
|
840
|
+
target.id,
|
|
841
|
+
...keys
|
|
842
|
+
]);
|
|
843
|
+
},
|
|
844
|
+
submit(exec, target, text) {
|
|
845
|
+
if (!text) {
|
|
846
|
+
exec("herdr", [
|
|
847
|
+
"pane",
|
|
848
|
+
"send-keys",
|
|
849
|
+
target.id,
|
|
850
|
+
"Enter"
|
|
851
|
+
]);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
exec("herdr", [
|
|
855
|
+
"pane",
|
|
856
|
+
"run",
|
|
857
|
+
target.id,
|
|
858
|
+
text
|
|
859
|
+
]);
|
|
860
|
+
},
|
|
861
|
+
/**
|
|
862
|
+
* 0.8.0 added a `truncated` boolean to the read result, and it is REQUIRED in the socket schema's
|
|
863
|
+
* `PaneReadResult` — but it is not reachable from here, and that is a fact about the CLI rather than
|
|
864
|
+
* a choice: `herdr pane read` prints the pane's bare TEXT, no envelope, and its only 0.8.0 additions
|
|
865
|
+
* are `--format`/`--ansi`/`--raw`, which select the text's escaping. There is no `--json`. So the
|
|
866
|
+
* flag rides the socket API this adapter deliberately does not speak, and the one CLI surface that
|
|
867
|
+
* does hand back the envelope is `pane wait-output` (`.result.read.truncated` — seen live on 0.8.0).
|
|
868
|
+
* Surfacing truncation therefore costs a return-shape change, not a flag; see issue #100, which owns
|
|
869
|
+
* it across all four backends. Noted here so the next reader does not re-derive the dead end.
|
|
870
|
+
*/
|
|
871
|
+
read(exec, target, opts) {
|
|
872
|
+
if (opts?.lines === "all") {
|
|
873
|
+
const text = paneRead(exec, target, "recent", FULL_SCROLLBACK_LINES);
|
|
874
|
+
return opts.truncation ? {
|
|
875
|
+
text,
|
|
876
|
+
truncated: false
|
|
877
|
+
} : { text };
|
|
878
|
+
}
|
|
879
|
+
const text = paneRead(exec, target, "visible", opts?.lines);
|
|
880
|
+
if (!opts?.truncation) return { text };
|
|
881
|
+
return {
|
|
882
|
+
text,
|
|
883
|
+
truncated: isReadTruncated(text, paneRead(exec, target, "recent", capturedRows(text) + 1))
|
|
884
|
+
};
|
|
885
|
+
},
|
|
886
|
+
/**
|
|
887
|
+
* The one backend with a NATIVE wait: `pane wait-output` blocks in herdr itself (arrived in 0.7.5,
|
|
888
|
+
* still native in 0.8.0), so no poll loop is run here and no snapshot is pulled across the CLI
|
|
889
|
+
* boundary on every tick.
|
|
890
|
+
*
|
|
891
|
+
* `--source visible` is pinned rather than left to herdr's own default (`recent_unwrapped`, verified
|
|
892
|
+
* against 0.7.5 — the help still says `recent` in 0.8.0). The seam's rule is that a wait searches exactly what
|
|
893
|
+
* `read` returns, and `read` pins `visible` here; taking the default would make the same wait mean a
|
|
894
|
+
* different snapshot on this backend than on every polling one.
|
|
895
|
+
*
|
|
896
|
+
* Telling a TIMEOUT (an answer) from a broken wait (a failure) is the whole difficulty, because herdr
|
|
897
|
+
* spells both the same way: exit 1 with an error envelope on stderr, so `Exec` yields `null` for
|
|
898
|
+
* either. Two tiers answer it, in order:
|
|
899
|
+
*
|
|
900
|
+
* 1. **The envelope's `code`**, when the runner captured stderr into `lastError` (re-verified against
|
|
901
|
+
* 0.8.0: `{"error":{"code":"timeout",…}}` vs `{"error":{"code":"pane_not_found",…}}`). Exact.
|
|
902
|
+
* 2. **A live pane that actually consumed the deadline**, when it did not. `Exec.lastError` is
|
|
903
|
+
* specified as a diagnostic and NEVER a control-flow signal — a runner that discards stderr must
|
|
904
|
+
* still work — so the code cannot be the only answer. Liveness alone is not enough either, and the
|
|
905
|
+
* reason is a whole released version of the backend: herdr 0.7.4 has no `pane wait-output` at all,
|
|
906
|
+
* so it answers with clap's usage text (not an envelope) INSTANTLY, and a liveness-only rule reads
|
|
907
|
+
* that as "timed out" — a silently wrong answer for a wait that never ran. Elapsed time is the fact
|
|
908
|
+
* that separates them and needs no stderr: a wait that returns in a fraction of its own timeout did
|
|
909
|
+
* not wait. Both must hold — the pane is live AND the deadline was spent — or this throws.
|
|
910
|
+
*
|
|
911
|
+
* A timeout costs ONE extra `read`, because herdr's timeout envelope carries no snapshot and the seam
|
|
912
|
+
* promises the caller the evidence its verdict was reached on. It is taken at the deadline, so it is
|
|
913
|
+
* the same "last look at the pane" a polling backend returns, one poll interval later.
|
|
914
|
+
*/
|
|
915
|
+
async waitForOutput(exec, target, opts) {
|
|
916
|
+
assertWaitPattern(opts);
|
|
917
|
+
const now = opts.now ?? (() => Date.now());
|
|
918
|
+
const pattern = opts.match != null ? ["--match", opts.match] : ["--regex", opts.regex];
|
|
919
|
+
const args = [
|
|
920
|
+
"pane",
|
|
921
|
+
"wait-output",
|
|
922
|
+
target.id,
|
|
923
|
+
"--source",
|
|
924
|
+
"visible",
|
|
925
|
+
"--timeout",
|
|
926
|
+
String(opts.timeoutMs)
|
|
927
|
+
];
|
|
928
|
+
args.push(...pattern);
|
|
929
|
+
if (opts.lines != null) args.push("--lines", String(opts.lines));
|
|
930
|
+
const started = now();
|
|
931
|
+
const out = exec("herdr", args);
|
|
932
|
+
if (out == null) {
|
|
933
|
+
if (!isHerdrWaitTimeout(exec, target, opts.timeoutMs, now() - started)) throw new Error(withReason(exec, `herdr pane wait-output failed for pane ${target.id}`));
|
|
934
|
+
const readOpts = opts.lines != null ? { lines: opts.lines } : void 0;
|
|
935
|
+
return {
|
|
936
|
+
matched: false,
|
|
937
|
+
output: herdrMuxAdapter.read(exec, target, readOpts).text
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
return parseWaitOutput(out);
|
|
941
|
+
},
|
|
942
|
+
focus(exec, target) {
|
|
943
|
+
const { workspaceId, tabId } = parsePaneLocation$2(exec("herdr", [
|
|
944
|
+
"pane",
|
|
945
|
+
"get",
|
|
946
|
+
target.id
|
|
947
|
+
]), target.id);
|
|
948
|
+
exec("herdr", [
|
|
949
|
+
"workspace",
|
|
950
|
+
"focus",
|
|
951
|
+
workspaceId
|
|
952
|
+
]);
|
|
953
|
+
exec("herdr", [
|
|
954
|
+
"tab",
|
|
955
|
+
"focus",
|
|
956
|
+
tabId
|
|
957
|
+
]);
|
|
958
|
+
},
|
|
959
|
+
teardown(exec, target) {
|
|
960
|
+
exec("herdr", [
|
|
961
|
+
"pane",
|
|
962
|
+
"close",
|
|
963
|
+
target.id
|
|
964
|
+
]);
|
|
965
|
+
},
|
|
966
|
+
paneExists(exec, target) {
|
|
967
|
+
return exec("herdr", [
|
|
968
|
+
"pane",
|
|
969
|
+
"read",
|
|
970
|
+
target.id,
|
|
971
|
+
"--source",
|
|
972
|
+
"visible"
|
|
973
|
+
]) !== null;
|
|
974
|
+
},
|
|
975
|
+
isPaneFocused(exec, target) {
|
|
976
|
+
const out = exec("herdr", [
|
|
977
|
+
"pane",
|
|
978
|
+
"get",
|
|
979
|
+
target.id
|
|
980
|
+
]);
|
|
981
|
+
if (out == null) return void 0;
|
|
982
|
+
try {
|
|
983
|
+
const focused = JSON.parse(out)?.result?.pane?.focused;
|
|
984
|
+
return typeof focused === "boolean" ? focused : void 0;
|
|
985
|
+
} catch {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
},
|
|
989
|
+
listPanes(exec) {
|
|
990
|
+
const out = exec("herdr", ["pane", "list"]);
|
|
991
|
+
if (!out) return [];
|
|
992
|
+
let panes;
|
|
993
|
+
try {
|
|
994
|
+
panes = JSON.parse(out)?.result?.panes;
|
|
995
|
+
} catch {
|
|
996
|
+
return [];
|
|
997
|
+
}
|
|
998
|
+
if (!Array.isArray(panes)) return [];
|
|
999
|
+
return panes.filter((p) => typeof p?.pane_id === "string").map((p) => {
|
|
1000
|
+
const harness = p.agent || void 0;
|
|
1001
|
+
const label = p.label || void 0;
|
|
1002
|
+
const agentStatus = toAgentStatus(p.agent_status);
|
|
1003
|
+
return {
|
|
1004
|
+
id: p.pane_id,
|
|
1005
|
+
mux: "herdr",
|
|
1006
|
+
floating: false,
|
|
1007
|
+
...harness !== void 0 ? { harness } : {},
|
|
1008
|
+
...agentStatus !== void 0 ? { agentStatus } : {},
|
|
1009
|
+
...p.cwd !== void 0 ? { cwd: p.cwd } : {},
|
|
1010
|
+
...label !== void 0 ? { label } : {}
|
|
1011
|
+
};
|
|
1012
|
+
});
|
|
1013
|
+
},
|
|
1014
|
+
regions: {
|
|
1015
|
+
describeRegion(exec, target) {
|
|
1016
|
+
return herdrRegionPanes(exec, target.id, herdrPaneDetails(exec));
|
|
1017
|
+
},
|
|
1018
|
+
/**
|
|
1019
|
+
* `pane resize --direction <d> --amount <f>` — a signed DELTA on the enclosing split's ratio,
|
|
1020
|
+
* which is why the current ratio has to be read first. herdr's `--direction` names where the
|
|
1021
|
+
* DIVIDER moves, not which pane grows: `right`/`down` raise the split's ratio and `left`/`up`
|
|
1022
|
+
* lower it, whichever side of it the `--pane` sits on. So the target's side never enters the
|
|
1023
|
+
* direction — only the sign of the delta does.
|
|
1024
|
+
*
|
|
1025
|
+
* The current ratio is derived from the RECTS, not read off `layout.splits[].ratio`, for
|
|
1026
|
+
* `describeRegion`'s reason exactly: the splits array is flat and its parent links live only in
|
|
1027
|
+
* an undocumented id convention (`split_1_0`), so matching a pane to its split there would bet on
|
|
1028
|
+
* a spelling herdr never promised. The rects say the same thing in a fact it does promise.
|
|
1029
|
+
*
|
|
1030
|
+
* Verified against a live herdr 0.8.2 (`mux.herdr.integration.test.ts`), which is also where the
|
|
1031
|
+
* `--direction` semantics above were established: `--direction right --amount 0.1` moved a 0.5
|
|
1032
|
+
* split to 0.6 whether the `--pane` was the left one or the right one, and on a nested region
|
|
1033
|
+
* `--direction down` resized the enclosing stacked split rather than the outer one.
|
|
1034
|
+
*/
|
|
1035
|
+
resizePane(exec, target, ratio) {
|
|
1036
|
+
const split = enclosingSplit(herdrRegionPanes(exec, target.id, herdrPaneDetails(exec)), target.id);
|
|
1037
|
+
if (!split) throw new Error(`herdr pane ${target.id} is the only pane in its region — there is no split to resize`);
|
|
1038
|
+
const delta = toHerdrResizeDelta(split, ratio);
|
|
1039
|
+
if (delta === 0) return;
|
|
1040
|
+
const direction = herdrResizeDirection(split.direction, delta);
|
|
1041
|
+
if (exec("herdr", [
|
|
1042
|
+
"pane",
|
|
1043
|
+
"resize",
|
|
1044
|
+
"--pane",
|
|
1045
|
+
target.id,
|
|
1046
|
+
"--direction",
|
|
1047
|
+
direction,
|
|
1048
|
+
"--amount",
|
|
1049
|
+
String(Math.abs(delta))
|
|
1050
|
+
]) === null) throw new Error(withReason(exec, `herdr could not resize pane ${target.id}`));
|
|
1051
|
+
},
|
|
1052
|
+
/**
|
|
1053
|
+
* herdr HAS a workspace tier, so the workspace is a fact the backend holds rather than one
|
|
1054
|
+
* cyber-mux has to reconstruct: the caller's pane names its `workspace_id`, `tab list --workspace`
|
|
1055
|
+
* enumerates that workspace's tabs, and `pane list --workspace` hands back every pane already
|
|
1056
|
+
* stamped with the tab it sits in. No grouping tag is read here and none is written — the tier IS
|
|
1057
|
+
* the group, which is exactly why `open` ignores `workspaceGroup` on this backend.
|
|
1058
|
+
*
|
|
1059
|
+
* The one indirection: geometry is per-PANE (`pane layout --pane`), never per-tab, so each tab's
|
|
1060
|
+
* rects are fetched through any one pane that sits in it. That is safe and race-free, and both
|
|
1061
|
+
* halves were established against 0.7.4: `pane layout` reports live geometry for an UNFOCUSED tab
|
|
1062
|
+
* in a DIFFERENT workspace, so nothing has to be focused first and nothing moves while this runs.
|
|
1063
|
+
*
|
|
1064
|
+
* herdr's own native per-tab layout export would be the obvious road — it takes a `tab_id` — but
|
|
1065
|
+
* `layout` is still NOT a CLI verb in 0.8.0 (its top-level help lists no such subcommand); it is
|
|
1066
|
+
* socket-API-only, and this adapter speaks the CLI by design (so it composes with the synchronous
|
|
1067
|
+
* `Exec` seam). The road is closed, hence the pane indirection.
|
|
1068
|
+
*/
|
|
1069
|
+
describeWorkspace(exec, target) {
|
|
1070
|
+
const { workspaceId } = parsePaneRecord(exec("herdr", [
|
|
1071
|
+
"pane",
|
|
1072
|
+
"get",
|
|
1073
|
+
target.id
|
|
1074
|
+
]));
|
|
1075
|
+
if (!workspaceId) throw new Error(withReason(exec, `herdr could not resolve the workspace around pane ${target.id}`));
|
|
1076
|
+
const out = exec("herdr", [
|
|
1077
|
+
"tab",
|
|
1078
|
+
"list",
|
|
1079
|
+
"--workspace",
|
|
1080
|
+
workspaceId
|
|
1081
|
+
]);
|
|
1082
|
+
if (!out) throw new Error(withReason(exec, `herdr could not enumerate the tabs of workspace ${workspaceId}`));
|
|
1083
|
+
let reported;
|
|
1084
|
+
try {
|
|
1085
|
+
reported = JSON.parse(out)?.result?.tabs;
|
|
1086
|
+
} catch {
|
|
1087
|
+
throw new Error(`herdr tab list returned unparseable output: ${out.slice(0, 200)}`);
|
|
1088
|
+
}
|
|
1089
|
+
if (!Array.isArray(reported) || reported.length === 0) throw new Error(`herdr reported no tabs in workspace ${workspaceId}: ${out.slice(0, 200)}`);
|
|
1090
|
+
const details = herdrPaneDetails(exec, workspaceId);
|
|
1091
|
+
const tabs = [];
|
|
1092
|
+
for (const reportedTab of reported) {
|
|
1093
|
+
if (typeof reportedTab?.tab_id !== "string") continue;
|
|
1094
|
+
const tabId = reportedTab.tab_id;
|
|
1095
|
+
const anchor = [...details].find(([, detail]) => detail.tab === tabId)?.[0];
|
|
1096
|
+
if (!anchor) throw new Error(`herdr reported no panes in tab ${tabId} of workspace ${workspaceId}`);
|
|
1097
|
+
const tab = {
|
|
1098
|
+
id: tabId,
|
|
1099
|
+
panes: herdrRegionPanes(exec, anchor, details)
|
|
1100
|
+
};
|
|
1101
|
+
if (typeof reportedTab.label === "string" && reportedTab.label !== "") tab.label = reportedTab.label;
|
|
1102
|
+
tabs.push(tab);
|
|
1103
|
+
}
|
|
1104
|
+
if (tabs.length === 0) throw new Error(`herdr reported no usable tabs in workspace ${workspaceId}: ${out.slice(0, 200)}`);
|
|
1105
|
+
return tabs;
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
agentLifecycle: { waitForState(exec, target, opts) {
|
|
1109
|
+
const until = opts.until ?? [];
|
|
1110
|
+
const status = parseReachedAgentStatus(exec("herdr", [
|
|
1111
|
+
"agent",
|
|
1112
|
+
"wait",
|
|
1113
|
+
target.id,
|
|
1114
|
+
...until.flatMap((state) => ["--until", state]),
|
|
1115
|
+
...opts.timeoutMs != null ? ["--timeout", String(opts.timeoutMs)] : []
|
|
1116
|
+
]));
|
|
1117
|
+
if (!status) throw new Error(withReason(exec, `herdr agent wait reported no reached agent_status for pane ${target.id}`));
|
|
1118
|
+
return status;
|
|
1119
|
+
} }
|
|
1120
|
+
};
|
|
1121
|
+
/**
|
|
1122
|
+
* The set of `agent_status` values herdr reports — unchanged through 0.8.0, whose socket schema still
|
|
1123
|
+
* declares `AgentStatus` as exactly this enum, and whose `agent wait --until` still lists exactly these
|
|
1124
|
+
* five values. The runtime witness of the `AgentStatus`
|
|
1125
|
+
* type, so a string read off a herdr envelope can be NARROWED to it rather than cast. A value outside
|
|
1126
|
+
* this set is treated as absent (the feed said something this build does not model), never forced into
|
|
1127
|
+
* the type.
|
|
1128
|
+
*/
|
|
1129
|
+
const AGENT_STATUSES = [
|
|
1130
|
+
"idle",
|
|
1131
|
+
"working",
|
|
1132
|
+
"blocked",
|
|
1133
|
+
"done",
|
|
1134
|
+
"unknown"
|
|
1135
|
+
];
|
|
1136
|
+
/** A value narrowed to `AgentStatus`, or `undefined` for anything else (a non-string, an empty string,
|
|
1137
|
+
* or a status this build does not model) — the normalization both the listing and the wait share. */
|
|
1138
|
+
function toAgentStatus(value) {
|
|
1139
|
+
return typeof value === "string" && AGENT_STATUSES.includes(value) ? value : void 0;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* The `AgentStatus` a `herdr agent wait` run reached, read defensively from its JSON envelope —
|
|
1143
|
+
* `{"result":{"agent":{…,"agent_status":"idle",…},"type":"agent_info"}}` (verified against 0.7.5), so
|
|
1144
|
+
* the reached status lives at `.result.agent.agent_status`. Every unresolvable shape — `out` is null
|
|
1145
|
+
* (an Exec failure), the JSON does not parse, or the field is missing/empty/unmodeled — folds to
|
|
1146
|
+
* `undefined`, exactly as `parsePaneRecord`/`isPaneFocused` fold, so the caller states its own failure.
|
|
1147
|
+
*/
|
|
1148
|
+
function parseReachedAgentStatus(out) {
|
|
1149
|
+
if (out == null) return void 0;
|
|
1150
|
+
try {
|
|
1151
|
+
return toAgentStatus(JSON.parse(out)?.result?.agent?.agent_status);
|
|
1152
|
+
} catch {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* The rects of the region `paneId` sits in, joined with the cwd/label half.
|
|
1158
|
+
*
|
|
1159
|
+
* Two sources, because herdr splits the answer across two verbs: `pane layout` reports the region's
|
|
1160
|
+
* rects (`layout.panes[].rect`) but carries no cwd and no label, while `pane list` carries both and
|
|
1161
|
+
* no geometry. Neither alone can build a template — hence `details` is passed IN, so a caller reading
|
|
1162
|
+
* many tabs pays for that list once rather than once per tab.
|
|
1163
|
+
*
|
|
1164
|
+
* `layout.splits[]` is deliberately ignored even though it reports `direction` and `ratio` outright.
|
|
1165
|
+
* It is FLAT — `[{id:"split_0_root",...},{id:"split_1_0",...}]` — so the tree is recoverable only by
|
|
1166
|
+
* parsing the parent out of that id string, a convention herdr's CLI help never documents and could
|
|
1167
|
+
* respell without warning. The rects say the same thing in a fact herdr does promise, so the
|
|
1168
|
+
* derivation runs off those; see `RegionInspector.describeRegion` in `mux.ts`.
|
|
1169
|
+
*/
|
|
1170
|
+
function herdrRegionPanes(exec, paneId, details) {
|
|
1171
|
+
const out = exec("herdr", [
|
|
1172
|
+
"pane",
|
|
1173
|
+
"layout",
|
|
1174
|
+
"--pane",
|
|
1175
|
+
paneId
|
|
1176
|
+
]);
|
|
1177
|
+
if (!out) throw new Error(withReason(exec, `herdr could not describe the region around pane ${paneId}`));
|
|
1178
|
+
let reported;
|
|
1179
|
+
try {
|
|
1180
|
+
reported = JSON.parse(out)?.result?.layout?.panes;
|
|
1181
|
+
} catch {
|
|
1182
|
+
throw new Error(`herdr pane layout returned unparseable output: ${out.slice(0, 200)}`);
|
|
1183
|
+
}
|
|
1184
|
+
if (!Array.isArray(reported) || reported.length === 0) throw new Error(`herdr pane layout reported no panes for ${paneId}: ${out.slice(0, 200)}`);
|
|
1185
|
+
return reported.filter((p) => typeof p?.pane_id === "string").map((p) => {
|
|
1186
|
+
const detail = details.get(p.pane_id);
|
|
1187
|
+
const pane = {
|
|
1188
|
+
id: p.pane_id,
|
|
1189
|
+
rect: {
|
|
1190
|
+
x: p.rect?.["x"] ?? 0,
|
|
1191
|
+
y: p.rect?.["y"] ?? 0,
|
|
1192
|
+
width: p.rect?.["width"] ?? 0,
|
|
1193
|
+
height: p.rect?.["height"] ?? 0
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
if (detail?.cwd) pane.cwd = detail.cwd;
|
|
1197
|
+
if (detail?.label) pane.label = detail.label;
|
|
1198
|
+
return pane;
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Each pane's cwd, label and tab, keyed by pane id — the half `pane layout` does not report.
|
|
1203
|
+
*
|
|
1204
|
+
* `workspace` scopes the list to one workspace when the caller has one to scope by; omitting it lists
|
|
1205
|
+
* every pane herdr can see, which is what a single-region read wants (it keys by pane id and never
|
|
1206
|
+
* cares which workspace a pane came from).
|
|
1207
|
+
*/
|
|
1208
|
+
function herdrPaneDetails(exec, workspace) {
|
|
1209
|
+
const details = /* @__PURE__ */ new Map();
|
|
1210
|
+
const out = exec("herdr", [
|
|
1211
|
+
"pane",
|
|
1212
|
+
"list",
|
|
1213
|
+
...workspace ? ["--workspace", workspace] : []
|
|
1214
|
+
]);
|
|
1215
|
+
if (!out) return details;
|
|
1216
|
+
let panes;
|
|
1217
|
+
try {
|
|
1218
|
+
panes = JSON.parse(out)?.result?.panes;
|
|
1219
|
+
} catch {
|
|
1220
|
+
return details;
|
|
1221
|
+
}
|
|
1222
|
+
if (!Array.isArray(panes)) return details;
|
|
1223
|
+
for (const pane of panes) {
|
|
1224
|
+
if (typeof pane?.pane_id !== "string") continue;
|
|
1225
|
+
details.set(pane.pane_id, {
|
|
1226
|
+
cwd: pane.cwd,
|
|
1227
|
+
label: pane.label,
|
|
1228
|
+
tab: pane.tab_id
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return details;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* herdr's repeatable `--env KEY=VALUE` — spelled the same way by exactly three verbs: `pane split`,
|
|
1235
|
+
* `workspace create` and `tab create`, each backed by a native `env` Record in the socket schema
|
|
1236
|
+
* (protocol 16).
|
|
1237
|
+
*
|
|
1238
|
+
* `worktree create`/`worktree open` are deliberately NOT in that list: their params are
|
|
1239
|
+
* `[base, branch, cwd, focus, label, path, workspace_id]` and
|
|
1240
|
+
* `[branch, cwd, focus, label, path, workspace_id]` — no `env` — and herdr rejects the flag with
|
|
1241
|
+
* `unknown option: --env`. A caller needing env on that route uses the command-prefix fallback.
|
|
1242
|
+
* Re-verified against 0.8.0: both param sets are unchanged in protocol 19's schema, and a live
|
|
1243
|
+
* `worktree create --env` there still answers `unknown option: --env`.
|
|
1244
|
+
*/
|
|
1245
|
+
function envFlags(env) {
|
|
1246
|
+
return env ? Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]) : [];
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* The seam's `ratio` as the SIGNED delta `pane resize --amount` wants, in the same fraction-of-the-
|
|
1250
|
+
* split-region units herdr's own `--ratio` uses at birth.
|
|
1251
|
+
*
|
|
1252
|
+
* Both numbers are the FIRST side's fraction, so the target's side is folded in here (`1 - ratio` when
|
|
1253
|
+
* the target is the second side) and the direction below reads only the sign. Measured against the
|
|
1254
|
+
* split's current ratio as `region-tree.ts` derives it, never against `layout.splits[].ratio` — one
|
|
1255
|
+
* definition of "what this split is at" seam-wide is what makes a resize to the ratio a region was
|
|
1256
|
+
* just described at a no-op.
|
|
1257
|
+
*/
|
|
1258
|
+
function toHerdrResizeDelta(split, ratio) {
|
|
1259
|
+
assertRatioInRange(ratio);
|
|
1260
|
+
const delta = (split.targetIsFirst ? ratio : 1 - ratio) - split.firstRatio;
|
|
1261
|
+
return Math.round(delta * 1e6) / 1e6;
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Which way herdr moves the divider for a delta of this sign, on a split of this axis.
|
|
1265
|
+
*
|
|
1266
|
+
* `right`/`down` RAISE the enclosing split's ratio and `left`/`up` lower it — established against a
|
|
1267
|
+
* live 0.8.2 rather than read off `--help`, which lists the four values and says nothing about what
|
|
1268
|
+
* they move. The axis picks the pair because herdr resolves `--direction` against the nearest ancestor
|
|
1269
|
+
* split on that axis: asking `right` of a pane inside a stacked split walks past it to the outer one,
|
|
1270
|
+
* which would resize a split the caller never named.
|
|
1271
|
+
*/
|
|
1272
|
+
function herdrResizeDirection(axis, delta) {
|
|
1273
|
+
if (axis === "right") return delta > 0 ? "right" : "left";
|
|
1274
|
+
return delta > 0 ? "down" : "up";
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* `--ratio` takes the seam's number VERBATIM — herdr sizes the ORIGINAL pane, so no inversion, unlike
|
|
1278
|
+
* tmux's `-l` and wezterm's `--percent`. The guard is the same one those two render helpers call: the
|
|
1279
|
+
* seam refuses an out-of-range ratio here rather than pass `--ratio 5` (or `0`) through to a split herdr
|
|
1280
|
+
* would then size wrong.
|
|
1281
|
+
*/
|
|
1282
|
+
function toHerdrRatio(ratio) {
|
|
1283
|
+
assertRatioInRange(ratio);
|
|
1284
|
+
return String(ratio);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Launch a command in a worktree's root pane, carrying env the worktree verb could not set at birth.
|
|
1288
|
+
* The prefix-or-warn rule is the seam's (`env-fallback.ts`); this is the one route that invokes it,
|
|
1289
|
+
* because it is the one route that loses env. With a command, env rides in as a prefix; with none and
|
|
1290
|
+
* env asked for, it warns to stderr (stdout stays machine-readable) rather than dropping in silence.
|
|
1291
|
+
*/
|
|
1292
|
+
function carryLaunch(exec, target, env, launch) {
|
|
1293
|
+
const fallback = envFallback(env, launch);
|
|
1294
|
+
if (fallback.kind === "dropped") {
|
|
1295
|
+
process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this worktree's workspace and no command was given to carry it — herdr worktree create/open take no env parameter
|
|
1296
|
+
`);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (fallback.command !== void 0) herdrMuxAdapter.submit(exec, target, fallback.command);
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* `herdr pane split` emits a JSON envelope, not a bare id:
|
|
1303
|
+
* `{"id":"cli:pane:split","result":{"pane":{"pane_id":"w3:pB", ...},"type":"pane_info"}}`.
|
|
1304
|
+
* The pane id herdr's other `pane` subcommands accept lives at `.result.pane.pane_id`. Extract it —
|
|
1305
|
+
* passing the whole blob downstream lands it in a filename and blows the path length limit.
|
|
1306
|
+
*/
|
|
1307
|
+
function parsePaneId(out) {
|
|
1308
|
+
return parseOpenedPane(out, "herdr pane split", "pane");
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* `herdr pane get <id>` emits `{"result":{"pane":{"workspace_id":...,"tab_id":...,...}}}`, or an
|
|
1312
|
+
* error envelope when the id no longer names a live pane. Every unresolvable shape — `out` is null
|
|
1313
|
+
* (an Exec failure), the JSON does not parse, or a field is missing/empty/not a string — folds to the
|
|
1314
|
+
* field simply being absent, so each caller states its OWN failure rather than inheriting one
|
|
1315
|
+
* phrased for somebody else's verb.
|
|
1316
|
+
*/
|
|
1317
|
+
function parsePaneRecord(out) {
|
|
1318
|
+
if (out == null) return {};
|
|
1319
|
+
try {
|
|
1320
|
+
const pane = JSON.parse(out)?.result?.pane;
|
|
1321
|
+
return {
|
|
1322
|
+
workspaceId: nonEmpty(pane?.workspace_id),
|
|
1323
|
+
tabId: nonEmpty(pane?.tab_id)
|
|
1324
|
+
};
|
|
1325
|
+
} catch {
|
|
1326
|
+
return {};
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
function nonEmpty(value) {
|
|
1330
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* The `code` of a herdr error envelope, when the runner captured one — how a wait's TIMEOUT (an answer)
|
|
1334
|
+
* is told from any other failure (a throw). Read from `Exec.lastError` because that is where herdr's
|
|
1335
|
+
* envelope lands: it is written to stderr with exit 1, so stdout is `null` for every failure alike and
|
|
1336
|
+
* the code is the only thing that separates them. Defensive throughout — no reason, unparseable JSON, or
|
|
1337
|
+
* a missing/non-string code all answer `undefined`, which routes to the throw rather than to a silent
|
|
1338
|
+
* "timed out" the backend never said.
|
|
1339
|
+
*/
|
|
1340
|
+
/**
|
|
1341
|
+
* How much of its own timeout a wait must actually spend before a failure is believed to BE that
|
|
1342
|
+
* timeout. A fraction rather than the whole, because process start-up and clock granularity make an
|
|
1343
|
+
* exact-or-greater comparison flaky on a real runner; wide enough that the case it exists to catch — a
|
|
1344
|
+
* herdr with no `wait-output` subcommand, which returns in milliseconds — is nowhere near it.
|
|
1345
|
+
*/
|
|
1346
|
+
const HERDR_WAIT_ELAPSED_RATIO = .9;
|
|
1347
|
+
/**
|
|
1348
|
+
* Whether a failed `pane wait-output` was the DEADLINE passing rather than the wait breaking — the
|
|
1349
|
+
* two-tier rule `waitForOutput` documents, kept out of the method so the tiers read as one decision.
|
|
1350
|
+
*
|
|
1351
|
+
* The envelope's code answers when the runner captured one. Otherwise the answer needs two facts, and
|
|
1352
|
+
* neither alone is enough: the pane must be LIVE (a gone pane is a failure, `pollForOutput`'s rule) and
|
|
1353
|
+
* the call must have SPENT the deadline (a wait that returned instantly never ran — herdr 0.7.4, whose
|
|
1354
|
+
* usage text for an unknown subcommand is not an envelope to read a code from).
|
|
1355
|
+
*/
|
|
1356
|
+
function isHerdrWaitTimeout(exec, target, timeoutMs, elapsedMs) {
|
|
1357
|
+
const code = herdrErrorCode(exec.lastError);
|
|
1358
|
+
if (code != null) return code === "timeout";
|
|
1359
|
+
if (elapsedMs < timeoutMs * HERDR_WAIT_ELAPSED_RATIO) return false;
|
|
1360
|
+
return herdrMuxAdapter.paneExists(exec, target);
|
|
1361
|
+
}
|
|
1362
|
+
function herdrErrorCode(reason) {
|
|
1363
|
+
if (!reason) return void 0;
|
|
1364
|
+
try {
|
|
1365
|
+
return nonEmpty(JSON.parse(reason)?.error?.code);
|
|
1366
|
+
} catch {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* A successful `pane wait-output` envelope: the snapshot it matched in, and the line it matched on.
|
|
1372
|
+
*
|
|
1373
|
+
* `matched` is `true` by construction — herdr exits 0 only on a match, so reaching here IS the match;
|
|
1374
|
+
* the parse only fills in the evidence. Defensive for the same reason `parsePaneRecord` is: a herdr
|
|
1375
|
+
* build that reshapes the envelope degrades to a match with no snapshot, never to a failed wait.
|
|
1376
|
+
*/
|
|
1377
|
+
function parseWaitOutput(out) {
|
|
1378
|
+
let text;
|
|
1379
|
+
let line;
|
|
1380
|
+
try {
|
|
1381
|
+
const result = JSON.parse(out)?.result;
|
|
1382
|
+
text = nonEmpty(result?.read?.text);
|
|
1383
|
+
line = nonEmpty(result?.matched_line);
|
|
1384
|
+
} catch {}
|
|
1385
|
+
return {
|
|
1386
|
+
matched: true,
|
|
1387
|
+
output: text ?? "",
|
|
1388
|
+
...line != null ? { matchedLine: line } : {}
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* The pane's workspace and tab, or a throw — so `focus` never issues a workspace/tab switch against a
|
|
1393
|
+
* pane it couldn't actually resolve.
|
|
1394
|
+
*/
|
|
1395
|
+
function parsePaneLocation$2(out, id) {
|
|
1396
|
+
const { workspaceId, tabId } = parsePaneRecord(out);
|
|
1397
|
+
if (!workspaceId || !tabId) throw new Error(`peer's pane ${id} could not be resolved to beam to`);
|
|
1398
|
+
return {
|
|
1399
|
+
workspaceId,
|
|
1400
|
+
tabId
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* herdr binds a git worktree to a workspace as a first-class record, and that binding is what its UI
|
|
1405
|
+
* groups a repo's checkouts by. Only `worktree create`/`worktree open` produce it: `git worktree add`
|
|
1406
|
+
* followed by `workspace create --cwd <checkout>` yields a workspace herdr does not know is a
|
|
1407
|
+
* worktree at all, left out of the group. Hence this capability — see `WorktreeWorkspaceCapability`
|
|
1408
|
+
* for what it deliberately does not own.
|
|
1409
|
+
*
|
|
1410
|
+
* Every call pins the source repo with `--cwd <primaryRoot>` rather than relying on the caller's
|
|
1411
|
+
* ambient process cwd (matching how the git adapter always passes `-C <primaryRoot>`), and opens
|
|
1412
|
+
* with `--no-focus` so spawning never steals the caller's attention.
|
|
1413
|
+
*/
|
|
1414
|
+
function herdrWorktreeCapability() {
|
|
1415
|
+
return {
|
|
1416
|
+
createInWorkspace(exec, opts) {
|
|
1417
|
+
const args = [
|
|
1418
|
+
"worktree",
|
|
1419
|
+
"create",
|
|
1420
|
+
"--cwd",
|
|
1421
|
+
opts.primaryRoot,
|
|
1422
|
+
"--branch",
|
|
1423
|
+
opts.branch,
|
|
1424
|
+
"--path",
|
|
1425
|
+
opts.path
|
|
1426
|
+
];
|
|
1427
|
+
if (opts.base) args.push("--base", opts.base);
|
|
1428
|
+
if (opts.label) args.push("--label", opts.label);
|
|
1429
|
+
args.push("--no-focus");
|
|
1430
|
+
const out = exec("herdr", args);
|
|
1431
|
+
if (!out) throw new Error(withReason(exec, "herdr worktree create failed"));
|
|
1432
|
+
const created = parseWorktreeWorkspace(out, "herdr worktree create");
|
|
1433
|
+
carryLaunch(exec, created.target, opts.env, opts.launch);
|
|
1434
|
+
return created;
|
|
1435
|
+
},
|
|
1436
|
+
openInWorkspace(exec, opts) {
|
|
1437
|
+
const args = [
|
|
1438
|
+
"worktree",
|
|
1439
|
+
"open",
|
|
1440
|
+
"--cwd",
|
|
1441
|
+
opts.primaryRoot,
|
|
1442
|
+
"--path",
|
|
1443
|
+
opts.path
|
|
1444
|
+
];
|
|
1445
|
+
if (opts.label) args.push("--label", opts.label);
|
|
1446
|
+
args.push("--no-focus");
|
|
1447
|
+
const out = exec("herdr", args);
|
|
1448
|
+
if (!out) throw new Error(withReason(exec, "herdr worktree open failed"));
|
|
1449
|
+
const opened = parseWorktreeWorkspace(out, "herdr worktree open");
|
|
1450
|
+
carryLaunch(exec, opened.target, opts.env, opts.launch);
|
|
1451
|
+
return opened;
|
|
1452
|
+
},
|
|
1453
|
+
bindings(exec, opts) {
|
|
1454
|
+
return parseWorktreeBindings(exec("herdr", [
|
|
1455
|
+
"worktree",
|
|
1456
|
+
"list",
|
|
1457
|
+
"--cwd",
|
|
1458
|
+
opts.primaryRoot
|
|
1459
|
+
]));
|
|
1460
|
+
},
|
|
1461
|
+
releaseWorkspace(exec, workspace) {
|
|
1462
|
+
exec("herdr", [
|
|
1463
|
+
"workspace",
|
|
1464
|
+
"close",
|
|
1465
|
+
workspace
|
|
1466
|
+
]);
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* `herdr workspace create` and `herdr tab create` both emit their new root pane at
|
|
1472
|
+
* `.result.root_pane.pane_id` (a different path than `pane split`'s `.result.pane.pane_id`).
|
|
1473
|
+
* `label` names the command in error messages (e.g. "herdr workspace create").
|
|
1474
|
+
*/
|
|
1475
|
+
function parseRootPaneId(out, label) {
|
|
1476
|
+
return parseOpenedPane(out, label, "root_pane");
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Every pane herdr emits carries its own `workspace_id` alongside its `pane_id`, on EVERY route —
|
|
1480
|
+
* `workspace create` (which reports the workspace it just made), `tab create` (the workspace the tab
|
|
1481
|
+
* was created in), and `pane split` (the workspace the split landed in, i.e. the caller's). Re-verified
|
|
1482
|
+
* against herdr 0.8.0. That is why the workspace costs no extra call: it rides in on the same output
|
|
1483
|
+
* the pane id is already read from, so probing for it separately would buy nothing and cost a round
|
|
1484
|
+
* trip per open.
|
|
1485
|
+
*
|
|
1486
|
+
* The pane id is required — a route that cannot name its pane has failed. The workspace is NOT: it
|
|
1487
|
+
* is read opportunistically and left absent when missing rather than throwing, so a herdr build that
|
|
1488
|
+
* stops emitting it degrades to "cannot say" instead of breaking `open` outright. Absent is a
|
|
1489
|
+
* meaning this seam already has (`OpenedPane.workspace`); a hard failure here would be inventing a
|
|
1490
|
+
* new one for a field no caller is required to use.
|
|
1491
|
+
*/
|
|
1492
|
+
function parseOpenedPane(out, label, key) {
|
|
1493
|
+
let pane;
|
|
1494
|
+
try {
|
|
1495
|
+
pane = JSON.parse(out)?.result?.[key];
|
|
1496
|
+
} catch {
|
|
1497
|
+
throw new Error(`${label} returned unparseable output: ${out.slice(0, 200)}`);
|
|
1498
|
+
}
|
|
1499
|
+
const paneId = pane?.pane_id;
|
|
1500
|
+
if (typeof paneId !== "string" || paneId === "") throw new Error(`${label} output had no result.${key}.pane_id: ${out.slice(0, 200)}`);
|
|
1501
|
+
const tab = pane?.tab_id;
|
|
1502
|
+
if (typeof tab !== "string" || tab === "") throw new Error(`${label} output had no result.${key}.tab_id: ${out.slice(0, 200)}`);
|
|
1503
|
+
const workspace = pane?.workspace_id;
|
|
1504
|
+
return typeof workspace === "string" && workspace !== "" ? {
|
|
1505
|
+
id: paneId,
|
|
1506
|
+
tab,
|
|
1507
|
+
workspace
|
|
1508
|
+
} : {
|
|
1509
|
+
id: paneId,
|
|
1510
|
+
tab
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* `herdr worktree create` and `herdr worktree open` emit the same envelope: the root pane at
|
|
1515
|
+
* `.result.root_pane.pane_id` (as `workspace create` does), the checkout at
|
|
1516
|
+
* `.result.worktree.{path,branch}`, and the bound workspace at `.result.workspace.workspace_id`.
|
|
1517
|
+
* That workspace id IS the binding — the whole reason to route through these instead of plain git.
|
|
1518
|
+
* `label` names the command in error messages (e.g. "herdr worktree create").
|
|
1519
|
+
*
|
|
1520
|
+
* The root pane is read through `parseOpenedPane`, NOT re-parsed here: `root_pane` is the same record
|
|
1521
|
+
* `workspace create` emits, so it carries the same `tab_id`, and one spelling is what keeps the two
|
|
1522
|
+
* routes from disagreeing about a field both report. That tab is the region's root tab — what lets a
|
|
1523
|
+
* caller handed this workspace group or rename it without reaching for the pane id, which would be
|
|
1524
|
+
* green on tmux and silently broken on herdr.
|
|
1525
|
+
*/
|
|
1526
|
+
function parseWorktreeWorkspace(out, label) {
|
|
1527
|
+
let parsed;
|
|
1528
|
+
try {
|
|
1529
|
+
parsed = JSON.parse(out);
|
|
1530
|
+
} catch {
|
|
1531
|
+
throw new Error(`${label} returned unparseable output: ${out.slice(0, 200)}`);
|
|
1532
|
+
}
|
|
1533
|
+
const result = parsed?.result;
|
|
1534
|
+
const target = parseOpenedPane(out, label, "root_pane");
|
|
1535
|
+
const workspace = result?.workspace?.workspace_id;
|
|
1536
|
+
const path = result?.worktree?.path;
|
|
1537
|
+
const branch = result?.worktree?.branch;
|
|
1538
|
+
if (typeof path !== "string" || path === "" || typeof branch !== "string" || branch === "") throw new Error(`${label} output had no result.worktree.{path,branch}: ${out.slice(0, 200)}`);
|
|
1539
|
+
if (typeof workspace !== "string" || workspace === "") throw new Error(`${label} output had no result.workspace.workspace_id: ${out.slice(0, 200)}`);
|
|
1540
|
+
return {
|
|
1541
|
+
target,
|
|
1542
|
+
worktree: {
|
|
1543
|
+
root: resolve(path),
|
|
1544
|
+
branch
|
|
1545
|
+
},
|
|
1546
|
+
workspace
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* `herdr worktree list` reports every worktree of the repo, each carrying `open_workspace_id` ONLY
|
|
1551
|
+
* while a workspace is currently open on it. Everything else it reports (branch, linked, prunable)
|
|
1552
|
+
* is herdr re-reading git — deliberately ignored here; git answers those for every backend.
|
|
1553
|
+
* Defensive like `listPanes`: a query that cannot be read reports nothing rather than throwing.
|
|
1554
|
+
*/
|
|
1555
|
+
function parseWorktreeBindings(out) {
|
|
1556
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1557
|
+
if (!out) return bindings;
|
|
1558
|
+
let parsed;
|
|
1559
|
+
try {
|
|
1560
|
+
parsed = JSON.parse(out);
|
|
1561
|
+
} catch {
|
|
1562
|
+
return bindings;
|
|
1563
|
+
}
|
|
1564
|
+
const worktrees = parsed?.result?.worktrees ?? [];
|
|
1565
|
+
if (!Array.isArray(worktrees)) return bindings;
|
|
1566
|
+
for (const entry of worktrees) {
|
|
1567
|
+
const path = entry?.path;
|
|
1568
|
+
const workspace = entry?.open_workspace_id;
|
|
1569
|
+
if (typeof path === "string" && path !== "" && typeof workspace === "string" && workspace !== "") bindings.set(normalizeWorktreePath(path), workspace);
|
|
1570
|
+
}
|
|
1571
|
+
return bindings;
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* One spelling of `pane read`, taken by `read` for the snapshot AND for its truncation probe — so the
|
|
1575
|
+
* two differ only in the source and depth they are meant to differ in. `lines` omitted takes herdr's
|
|
1576
|
+
* own default window for the source.
|
|
1577
|
+
*/
|
|
1578
|
+
function paneRead(exec, target, source, lines) {
|
|
1579
|
+
const args = [
|
|
1580
|
+
"pane",
|
|
1581
|
+
"read",
|
|
1582
|
+
target.id,
|
|
1583
|
+
"--source",
|
|
1584
|
+
source
|
|
1585
|
+
];
|
|
1586
|
+
if (lines != null) args.push("--lines", String(lines));
|
|
1587
|
+
return exec("herdr", args) ?? "";
|
|
1588
|
+
}
|
|
1589
|
+
//#endregion
|
|
1590
|
+
//#region src/mux.otty.ts
|
|
1591
|
+
/**
|
|
1592
|
+
* otty backend — detected via `$OTTY_PANE_ID`. Drives otty's CLI through `otty pane <verb> …`
|
|
1593
|
+
* (https://docs.otty.sh/reference/cli), the same synchronous-CLI shape the other backends use.
|
|
1594
|
+
*
|
|
1595
|
+
* otty is a native terminal-centric workspace app with integrated multiplexing (Windows > Tabs >
|
|
1596
|
+
* Splits > Panes). Its hierarchy maps onto cyber-mux's placement tiers as:
|
|
1597
|
+
* - **Workspace** → Window (a new window, spawned via `otty open --new-window`)
|
|
1598
|
+
* - **Tab** → Tab (a new tab via `otty tab new`)
|
|
1599
|
+
* - **Pane** → Pane (a split via `otty pane split`)
|
|
1600
|
+
*
|
|
1601
|
+
* The env variable `$OTTY_PANE_ID` carries the caller's pane identity — analogous to `$TMUX_PANE`
|
|
1602
|
+
* or `$WEZTERM_PANE`. `$OTTY_SOCKET` is the IPC socket path (detection hint only).
|
|
1603
|
+
*
|
|
1604
|
+
* Probed from the otty docs only — otty is a GUI-only app not installed in this sandbox, so
|
|
1605
|
+
* nothing here carries the "verified against a live binary" claim `mux.tmux.ts`/`mux.herdr.ts`
|
|
1606
|
+
* make; it makes the same honest disclaimer the other GUI-based adapters do.
|
|
1607
|
+
*
|
|
1608
|
+
* Real capability shape from the docs:
|
|
1609
|
+
*
|
|
1610
|
+
* - **Pane is the terminal unit.** `OTTY_PANE_ID` is the self-identity env var and `LivePane.id`
|
|
1611
|
+
* carries a pane id. `--at tab` maps to `otty tab new`; `--at pane:*` maps to `otty pane split`.
|
|
1612
|
+
* - **Window is the workspace tier.** `otty open --new-window` creates a genuinely separate window,
|
|
1613
|
+
* reported as `OpenedPane.workspace`.
|
|
1614
|
+
* - **No `--env` on any route.** Like wezterm/zellij/cmux, env is native at no tier, so every open
|
|
1615
|
+
* rides the `envFallback` compensation (an `env K=V` prefix on the launch command, or a stderr
|
|
1616
|
+
* warning when there is no command to ride).
|
|
1617
|
+
* - **Split direction is explicit.** `otty pane split --right|--bottom` maps `pane:right`/`pane:down`.
|
|
1618
|
+
* - **`send-keys` mixes text and key tokens.** `otty pane send-keys --pane <id> -- "text" key:Enter`
|
|
1619
|
+
* can do both in one call. We implement `sendText` and `sendKeys` separately per the contract.
|
|
1620
|
+
* - **No pane geometry adapter.** `otty panes --json` does not report position, so `regions` is not
|
|
1621
|
+
* implementable. `template save` refuses on otty by naming the backend.
|
|
1622
|
+
* - **No git-worktree concept in the CLI.** No `worktree` subcommand, so — like tmux, wezterm,
|
|
1623
|
+
* zellij, and cmux — this backend never binds a worktree to a workspace; callers fall back to
|
|
1624
|
+
* plain git plus `open()`.
|
|
1625
|
+
*/
|
|
1626
|
+
function createOttyAdapter(deps) {
|
|
1627
|
+
const adapter = {
|
|
1628
|
+
name: "otty",
|
|
1629
|
+
canSizeSplits: false,
|
|
1630
|
+
/**
|
|
1631
|
+
* `false`, for cmux's two reasons exactly: no suppress-focus flag is documented on `pane split`,
|
|
1632
|
+
* `tab new`, or `open`, and `from` is honored by focusing the target pane first (`open` below),
|
|
1633
|
+
* so an open moves the user twice over.
|
|
1634
|
+
*
|
|
1635
|
+
* Read off otty's documented CLI, NOT verified against a live binary — no otty on the machine
|
|
1636
|
+
* this was written on, matching the rest of this header.
|
|
1637
|
+
*/
|
|
1638
|
+
opensWithoutStealingFocus: false,
|
|
1639
|
+
open(exec, opts) {
|
|
1640
|
+
const at = opts.at ?? "tab";
|
|
1641
|
+
if (at === "workspace") {
|
|
1642
|
+
const args = ["open", "--new-window"];
|
|
1643
|
+
if (opts.cwd) args.push(opts.cwd);
|
|
1644
|
+
const out = exec("otty", args);
|
|
1645
|
+
if (!out) throw new Error(withReason(exec, "otty open --new-window failed"));
|
|
1646
|
+
const parsed = parseOttyOutput(out);
|
|
1647
|
+
if (!parsed.window_id) throw new Error("otty open --new-window did not report the window id");
|
|
1648
|
+
const paneId = parsed.pane_id;
|
|
1649
|
+
if (!paneId) throw new Error("otty open --new-window did not report the initial pane id");
|
|
1650
|
+
const opened = openedPane$1(paneId, parsed.tab_id, parsed.window_id);
|
|
1651
|
+
if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
|
|
1652
|
+
runLaunch$2(adapter, exec, opened, opts.env, opts.launch);
|
|
1653
|
+
return opened;
|
|
1654
|
+
}
|
|
1655
|
+
if (at === "tab") {
|
|
1656
|
+
const args = ["tab", "new"];
|
|
1657
|
+
if (opts.cwd) args.push("--cwd", opts.cwd);
|
|
1658
|
+
const out = exec("otty", args);
|
|
1659
|
+
if (!out) throw new Error(withReason(exec, "otty tab new failed"));
|
|
1660
|
+
const parsed = parseOttyOutput(out);
|
|
1661
|
+
const paneId = parsed.pane_id;
|
|
1662
|
+
if (!paneId) throw new Error("otty tab new did not report the pane id");
|
|
1663
|
+
const opened = openedPane$1(paneId, parsed.tab_id, deps.window);
|
|
1664
|
+
if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
|
|
1665
|
+
runLaunch$2(adapter, exec, opened, opts.env, opts.launch);
|
|
1666
|
+
return opened;
|
|
1667
|
+
}
|
|
1668
|
+
if (at === "pane:float") refuseFloatingPane(adapter.name);
|
|
1669
|
+
if (opts.from) adapter.focus(exec, opts.from);
|
|
1670
|
+
const args = [
|
|
1671
|
+
"pane",
|
|
1672
|
+
"split",
|
|
1673
|
+
at === "pane:down" ? "--bottom" : "--right"
|
|
1674
|
+
];
|
|
1675
|
+
if (opts.cwd) args.push("--cwd", opts.cwd);
|
|
1676
|
+
const out = exec("otty", args);
|
|
1677
|
+
if (!out) throw new Error(withReason(exec, "otty pane split failed"));
|
|
1678
|
+
const parsed = parseOttyOutput(out);
|
|
1679
|
+
const paneId = parsed.pane_id;
|
|
1680
|
+
if (!paneId) throw new Error("otty pane split did not report the pane id");
|
|
1681
|
+
const opened = openedPane$1(paneId, parsed.tab_id, deps.window);
|
|
1682
|
+
if (opts.label) adapter.rename(exec, opened, "pane", opts.label);
|
|
1683
|
+
runLaunch$2(adapter, exec, opened, opts.env, opts.launch);
|
|
1684
|
+
return opened;
|
|
1685
|
+
},
|
|
1686
|
+
rename(exec, target, tier, name) {
|
|
1687
|
+
if (tier === "tab") {
|
|
1688
|
+
exec("otty", [
|
|
1689
|
+
"tab",
|
|
1690
|
+
"rename",
|
|
1691
|
+
"--tab",
|
|
1692
|
+
target.id,
|
|
1693
|
+
"--title",
|
|
1694
|
+
name
|
|
1695
|
+
]);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
exec("otty", [
|
|
1699
|
+
"pane",
|
|
1700
|
+
"rename",
|
|
1701
|
+
"--pane",
|
|
1702
|
+
target.id,
|
|
1703
|
+
"--title",
|
|
1704
|
+
name
|
|
1705
|
+
]);
|
|
1706
|
+
},
|
|
1707
|
+
group() {},
|
|
1708
|
+
sendText(exec, target, text) {
|
|
1709
|
+
exec("otty", [
|
|
1710
|
+
"pane",
|
|
1711
|
+
"send-keys",
|
|
1712
|
+
"--pane",
|
|
1713
|
+
target.id,
|
|
1714
|
+
"--",
|
|
1715
|
+
text
|
|
1716
|
+
]);
|
|
1717
|
+
},
|
|
1718
|
+
sendKeys(exec, target, keys) {
|
|
1719
|
+
const keyTokens = keys.map((k) => `key:${toOttyKey(k)}`);
|
|
1720
|
+
exec("otty", [
|
|
1721
|
+
"pane",
|
|
1722
|
+
"send-keys",
|
|
1723
|
+
"--pane",
|
|
1724
|
+
target.id,
|
|
1725
|
+
"--",
|
|
1726
|
+
...keyTokens
|
|
1727
|
+
]);
|
|
1728
|
+
},
|
|
1729
|
+
submit(exec, target, text) {
|
|
1730
|
+
if (!text) {
|
|
1731
|
+
adapter.sendKeys(exec, target, ["Enter"]);
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
exec("otty", [
|
|
1735
|
+
"pane",
|
|
1736
|
+
"send-keys",
|
|
1737
|
+
"--pane",
|
|
1738
|
+
target.id,
|
|
1739
|
+
"--",
|
|
1740
|
+
text,
|
|
1741
|
+
"key:Enter"
|
|
1742
|
+
]);
|
|
1743
|
+
},
|
|
1744
|
+
read(exec, target, opts) {
|
|
1745
|
+
const args = [
|
|
1746
|
+
"pane",
|
|
1747
|
+
"capture",
|
|
1748
|
+
"--pane",
|
|
1749
|
+
target.id
|
|
1750
|
+
];
|
|
1751
|
+
if (opts?.lines != null) args.push("--lines", String(opts.lines));
|
|
1752
|
+
return { text: exec("otty", args) ?? "" };
|
|
1753
|
+
},
|
|
1754
|
+
waitForOutput(exec, target, opts) {
|
|
1755
|
+
return pollForOutput(adapter, exec, target, opts);
|
|
1756
|
+
},
|
|
1757
|
+
focus(exec, target) {
|
|
1758
|
+
exec("otty", [
|
|
1759
|
+
"pane",
|
|
1760
|
+
"focus",
|
|
1761
|
+
"--pane",
|
|
1762
|
+
target.id
|
|
1763
|
+
]);
|
|
1764
|
+
},
|
|
1765
|
+
teardown(exec, target) {
|
|
1766
|
+
exec("otty", [
|
|
1767
|
+
"pane",
|
|
1768
|
+
"close",
|
|
1769
|
+
"--pane",
|
|
1770
|
+
target.id
|
|
1771
|
+
]);
|
|
1772
|
+
},
|
|
1773
|
+
paneExists(exec, target) {
|
|
1774
|
+
return listOttyPanes(exec).some((p) => p.id === target.id);
|
|
1775
|
+
},
|
|
1776
|
+
isPaneFocused(exec, target) {
|
|
1777
|
+
const found = listOttyPanes(exec).find((p) => p.id === target.id);
|
|
1778
|
+
if (!found) return void 0;
|
|
1779
|
+
return found.is_focused === true;
|
|
1780
|
+
},
|
|
1781
|
+
listPanes(exec) {
|
|
1782
|
+
return listOttyPanes(exec).map((p) => {
|
|
1783
|
+
const pane = {
|
|
1784
|
+
id: p.id,
|
|
1785
|
+
mux: "otty",
|
|
1786
|
+
floating: false
|
|
1787
|
+
};
|
|
1788
|
+
if (p.cwd) pane.cwd = p.cwd;
|
|
1789
|
+
if (p.title) pane.label = p.title;
|
|
1790
|
+
return pane;
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
return adapter;
|
|
1795
|
+
}
|
|
1796
|
+
function parseOttyOutput(out) {
|
|
1797
|
+
try {
|
|
1798
|
+
return JSON.parse(out);
|
|
1799
|
+
} catch {
|
|
1800
|
+
return {};
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
function listOttyPanes(exec) {
|
|
1804
|
+
const out = exec("otty", ["panes", "--json"]);
|
|
1805
|
+
if (!out) return [];
|
|
1806
|
+
let parsed;
|
|
1807
|
+
try {
|
|
1808
|
+
parsed = JSON.parse(out);
|
|
1809
|
+
} catch {
|
|
1810
|
+
return [];
|
|
1811
|
+
}
|
|
1812
|
+
if (!Array.isArray(parsed)) return [];
|
|
1813
|
+
const panes = [];
|
|
1814
|
+
for (const p of parsed) if (p?.pane_id) panes.push({
|
|
1815
|
+
id: p.pane_id,
|
|
1816
|
+
title: p.title,
|
|
1817
|
+
cwd: p.cwd,
|
|
1818
|
+
is_focused: p.is_focused
|
|
1819
|
+
});
|
|
1820
|
+
return panes;
|
|
1821
|
+
}
|
|
1822
|
+
function openedPane$1(paneId, tabId, window) {
|
|
1823
|
+
const opened = {
|
|
1824
|
+
id: paneId,
|
|
1825
|
+
tab: tabId ?? paneId
|
|
1826
|
+
};
|
|
1827
|
+
if (window) opened.workspace = window;
|
|
1828
|
+
return opened;
|
|
1829
|
+
}
|
|
1830
|
+
function runLaunch$2(adapter, exec, target, env, launch) {
|
|
1831
|
+
const fallback = envFallback(env, launch);
|
|
1832
|
+
if (fallback.kind === "dropped") {
|
|
1833
|
+
process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this otty pane — otty has no --env flag on pane split/tab new/open
|
|
1834
|
+
`);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* The core key vocabulary's otty spelling. otty uses PascalCase key names: Enter, Tab, Escape, etc.
|
|
1841
|
+
*/
|
|
1842
|
+
const OTTY_KEY_RENAMES = { "C-c": "Ctrl+c" };
|
|
1843
|
+
function toOttyKey(key) {
|
|
1844
|
+
return OTTY_KEY_RENAMES[key] ?? key;
|
|
1845
|
+
}
|
|
1846
|
+
//#endregion
|
|
1847
|
+
//#region src/mux.rmux.ts
|
|
1848
|
+
/**
|
|
1849
|
+
* rmux backend — an async Rust reimplementation of the tmux command language (`Helvesec/rmux`),
|
|
1850
|
+
* detected via `$RMUX`.
|
|
1851
|
+
*
|
|
1852
|
+
* **A near-copy of `mux.tmux.ts`, on purpose.** rmux reimplements ~90 commands under tmux's own
|
|
1853
|
+
* names, flags, target syntax and `#{…}` format vocabulary, so the argv this adapter emits is
|
|
1854
|
+
* tmux's argv with a different binary in front of it. The alternative — one shared implementation
|
|
1855
|
+
* parameterized by binary name — was considered and REJECTED; the reasoning is in the ADR log
|
|
1856
|
+
* (`.agents/spec/design/decisions`, `136-rmux-adapter`). The short form: rmux is a separate project
|
|
1857
|
+
* that TRACKS tmux rather than a tmux version, so every future divergence would land as a
|
|
1858
|
+
* conditional inside the tmux adapter, which is the seam's own argument against emulation; and the
|
|
1859
|
+
* two already differ in the one member that matters most here (`canFloatPanes`), whose long
|
|
1860
|
+
* justification on each side is about a *different binary's* command set. A shared file could not
|
|
1861
|
+
* carry both verification claims honestly, and this repo's comments are verification claims.
|
|
1862
|
+
*
|
|
1863
|
+
* **Probed against a live binary — rmux 0.10.0, 2026-08-26**, on an isolated socket
|
|
1864
|
+
* (`rmux -L … new-session -d`), not read off documentation. Every claim below that says "verified"
|
|
1865
|
+
* names a command that was actually run. What was NOT covered: nothing was driven on Windows or
|
|
1866
|
+
* macOS (rmux runs natively on both, and that portability is the strategic reason this backend
|
|
1867
|
+
* exists), and no rmux version other than 0.10.0 was exercised.
|
|
1868
|
+
*/
|
|
1869
|
+
/**
|
|
1870
|
+
* The rmux window user option `MuxOpenOptions.workspaceGroup` is stored in — the same `@`-prefixed
|
|
1871
|
+
* user option tmux uses, because rmux implements tmux's user-option mechanism unchanged.
|
|
1872
|
+
*
|
|
1873
|
+
* Verified live on 0.10.0: `set-option -w -t @0 @cm_ws grp1` then `list-windows -F '#{@cm_ws}'`
|
|
1874
|
+
* read `grp1` back, and `list-windows -f '#{==:#{@cm_ws},grp1}'` filtered SERVER-SIDE to that
|
|
1875
|
+
* window. Both halves matter — the read alone would leave the filter a docs claim.
|
|
1876
|
+
*
|
|
1877
|
+
* Deliberately the SAME option name as tmux's (`TMUX_WORKSPACE_GROUP_OPTION`), not a namespaced
|
|
1878
|
+
* twin: the tag identifies a cyber-mux workspace group, and the two backends never share a server,
|
|
1879
|
+
* so a second spelling would buy nothing and cost a way for the two to drift. Named here rather
|
|
1880
|
+
* than spelled at each use so this adapter's write side and every read side cannot drift.
|
|
1881
|
+
* Server-lifetime, like every window: it dies with the rmux daemon, along with the windows it tags.
|
|
1882
|
+
*/
|
|
1883
|
+
const RMUX_WORKSPACE_GROUP_OPTION = "@cm_ws";
|
|
1884
|
+
/**
|
|
1885
|
+
* The rmux window user option a grouped window's OWN name is stored in — the name the caller gave
|
|
1886
|
+
* the tab, beside the group id, because rmux inherits tmux's single `window_name` field and so
|
|
1887
|
+
* inherits the problem it creates.
|
|
1888
|
+
*
|
|
1889
|
+
* See `TMUX_TAB_NAME_OPTION` in `mux.tmux.ts` for the full argument; it transfers intact, because
|
|
1890
|
+
* the constraint is tmux's data model and rmux reimplements that model. In brief: a caller that
|
|
1891
|
+
* composes a display name out of a tab's name (`pool - editor`) has destroyed `editor`, splitting
|
|
1892
|
+
* on the separator is ambiguous, and reading the display name verbatim re-prefixes it on every
|
|
1893
|
+
* round trip. So the original is stored here and read back from here.
|
|
1894
|
+
*
|
|
1895
|
+
* Verified live on 0.10.0 alongside the group tag: `set-option -w -t @0 @cm_tab editor` read back
|
|
1896
|
+
* through `display-message -p -t %1 '#{@cm_tab}'` as `editor`.
|
|
1897
|
+
*/
|
|
1898
|
+
const RMUX_TAB_NAME_OPTION = "@cm_tab";
|
|
1899
|
+
/** rmux backend — detected via `$RMUX`. */
|
|
1900
|
+
const rmuxMuxAdapter = {
|
|
1901
|
+
name: "rmux",
|
|
1902
|
+
canSizeSplits: true,
|
|
1903
|
+
/**
|
|
1904
|
+
* Every route passes `-d`, so no open moves the attached client — the same answer tmux gives, and
|
|
1905
|
+
* for the same reason, since rmux reimplements tmux's command language and inherits the behavior
|
|
1906
|
+
* along with the flag. Verified live on 0.10.0 rather than inherited from that resemblance, on an
|
|
1907
|
+
* isolated socket: a bare `split-window` in a session focused on `%0` left `%1` active, `-d` left
|
|
1908
|
+
* focus on `%0`, and `-P -F` printed the new id either way. `new-window -d` was already here.
|
|
1909
|
+
*
|
|
1910
|
+
* There is no focus move to undo on top of that, as on tmux: `-t` targets the pane to split
|
|
1911
|
+
* directly, so rmux never has to VISIT a pane to choose it. And no third route to cover — rmux has
|
|
1912
|
+
* no `new-pane`, so `pane:float` is refused before any command is issued.
|
|
1913
|
+
*/
|
|
1914
|
+
opensWithoutStealingFocus: true,
|
|
1915
|
+
open(exec, opts) {
|
|
1916
|
+
const at = opts.at ?? "tab";
|
|
1917
|
+
const window = at === "workspace" || at === "tab";
|
|
1918
|
+
if (at === "pane:float") refuseFloatingPane(rmuxMuxAdapter.name);
|
|
1919
|
+
const env = opts.env ? Object.entries(opts.env).flatMap(([k, v]) => ["-e", `${k}=${v}`]) : [];
|
|
1920
|
+
const group = window && opts.workspaceGroup != null;
|
|
1921
|
+
const format = "#{pane_id} #{window_id}";
|
|
1922
|
+
let args;
|
|
1923
|
+
if (window) args = [
|
|
1924
|
+
"new-window",
|
|
1925
|
+
"-d",
|
|
1926
|
+
...env,
|
|
1927
|
+
"-c",
|
|
1928
|
+
opts.cwd,
|
|
1929
|
+
"-P",
|
|
1930
|
+
"-F",
|
|
1931
|
+
format
|
|
1932
|
+
];
|
|
1933
|
+
else {
|
|
1934
|
+
const from = opts.from ? ["-t", opts.from.id] : [];
|
|
1935
|
+
const size = opts.ratio != null ? ["-l", toRmuxSize(opts.ratio)] : [];
|
|
1936
|
+
args = [
|
|
1937
|
+
"split-window",
|
|
1938
|
+
"-d",
|
|
1939
|
+
at === "pane:down" ? "-v" : "-h",
|
|
1940
|
+
...from,
|
|
1941
|
+
...size,
|
|
1942
|
+
...env,
|
|
1943
|
+
"-c",
|
|
1944
|
+
opts.cwd,
|
|
1945
|
+
"-P",
|
|
1946
|
+
"-F",
|
|
1947
|
+
format
|
|
1948
|
+
];
|
|
1949
|
+
}
|
|
1950
|
+
if (window && opts.label) args.splice(1, 0, "-n", opts.label);
|
|
1951
|
+
const out = exec("rmux", args);
|
|
1952
|
+
if (!out) throw new Error(withReason(exec, `rmux ${args[0]} failed`));
|
|
1953
|
+
const [pane, windowId] = splitOpenReport$1(out, args[0]);
|
|
1954
|
+
const target = {
|
|
1955
|
+
id: pane,
|
|
1956
|
+
tab: windowId
|
|
1957
|
+
};
|
|
1958
|
+
if (group && windowId) rmuxMuxAdapter.group(exec, { id: windowId }, opts.workspaceGroup);
|
|
1959
|
+
if (!window && opts.label) rmuxMuxAdapter.rename(exec, target, "pane", opts.label);
|
|
1960
|
+
if (opts.launch) rmuxMuxAdapter.submit(exec, target, opts.launch);
|
|
1961
|
+
return target;
|
|
1962
|
+
},
|
|
1963
|
+
rename(exec, target, tier, name) {
|
|
1964
|
+
if (tier === "tab") {
|
|
1965
|
+
exec("rmux", [
|
|
1966
|
+
"rename-window",
|
|
1967
|
+
"-t",
|
|
1968
|
+
target.id,
|
|
1969
|
+
name
|
|
1970
|
+
]);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
exec("rmux", [
|
|
1974
|
+
"select-pane",
|
|
1975
|
+
"-t",
|
|
1976
|
+
target.id,
|
|
1977
|
+
"-T",
|
|
1978
|
+
name
|
|
1979
|
+
]);
|
|
1980
|
+
},
|
|
1981
|
+
group(exec, target, group, name) {
|
|
1982
|
+
exec("rmux", [
|
|
1983
|
+
"set-option",
|
|
1984
|
+
"-w",
|
|
1985
|
+
"-t",
|
|
1986
|
+
target.id,
|
|
1987
|
+
RMUX_WORKSPACE_GROUP_OPTION,
|
|
1988
|
+
group
|
|
1989
|
+
]);
|
|
1990
|
+
if (name !== void 0) exec("rmux", [
|
|
1991
|
+
"set-option",
|
|
1992
|
+
"-w",
|
|
1993
|
+
"-t",
|
|
1994
|
+
target.id,
|
|
1995
|
+
RMUX_TAB_NAME_OPTION,
|
|
1996
|
+
name
|
|
1997
|
+
]);
|
|
1998
|
+
},
|
|
1999
|
+
sendText(exec, target, text) {
|
|
2000
|
+
exec("rmux", [
|
|
2001
|
+
"send-keys",
|
|
2002
|
+
"-t",
|
|
2003
|
+
target.id,
|
|
2004
|
+
"-l",
|
|
2005
|
+
text
|
|
2006
|
+
]);
|
|
2007
|
+
},
|
|
2008
|
+
sendKeys(exec, target, keys) {
|
|
2009
|
+
exec("rmux", [
|
|
2010
|
+
"send-keys",
|
|
2011
|
+
"-t",
|
|
2012
|
+
target.id,
|
|
2013
|
+
...keys.map(toRmuxKey)
|
|
2014
|
+
]);
|
|
2015
|
+
},
|
|
2016
|
+
submit(exec, target, text) {
|
|
2017
|
+
if (!text) {
|
|
2018
|
+
exec("rmux", [
|
|
2019
|
+
"send-keys",
|
|
2020
|
+
"-t",
|
|
2021
|
+
target.id,
|
|
2022
|
+
"Enter"
|
|
2023
|
+
]);
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
rmuxMuxAdapter.sendText(exec, target, text);
|
|
2027
|
+
exec("rmux", [
|
|
2028
|
+
"send-keys",
|
|
2029
|
+
"-t",
|
|
2030
|
+
target.id,
|
|
2031
|
+
"Enter"
|
|
2032
|
+
]);
|
|
2033
|
+
},
|
|
2034
|
+
read(exec, target, opts) {
|
|
2035
|
+
const text = capturePane$1(exec, target, opts?.lines);
|
|
2036
|
+
if (!opts?.truncation) return { text };
|
|
2037
|
+
if (opts.lines === "all") return {
|
|
2038
|
+
text,
|
|
2039
|
+
truncated: false
|
|
2040
|
+
};
|
|
2041
|
+
return {
|
|
2042
|
+
text,
|
|
2043
|
+
truncated: isReadTruncated(text, capturePane$1(exec, target, (opts.lines ?? 0) + 1))
|
|
2044
|
+
};
|
|
2045
|
+
},
|
|
2046
|
+
waitForOutput(exec, target, opts) {
|
|
2047
|
+
return pollForOutput(rmuxMuxAdapter, exec, target, opts);
|
|
2048
|
+
},
|
|
2049
|
+
focus(exec, target) {
|
|
2050
|
+
const { sessionName, windowId } = parsePaneLocation$1(exec("rmux", [
|
|
2051
|
+
"list-panes",
|
|
2052
|
+
"-a",
|
|
2053
|
+
"-F",
|
|
2054
|
+
"#{pane_id} #{session_name} #{window_id}"
|
|
2055
|
+
]), target.id);
|
|
2056
|
+
exec("rmux", [
|
|
2057
|
+
"switch-client",
|
|
2058
|
+
"-t",
|
|
2059
|
+
sessionName
|
|
2060
|
+
]);
|
|
2061
|
+
exec("rmux", [
|
|
2062
|
+
"select-window",
|
|
2063
|
+
"-t",
|
|
2064
|
+
windowId
|
|
2065
|
+
]);
|
|
2066
|
+
exec("rmux", [
|
|
2067
|
+
"select-pane",
|
|
2068
|
+
"-t",
|
|
2069
|
+
target.id
|
|
2070
|
+
]);
|
|
2071
|
+
},
|
|
2072
|
+
teardown(exec, target) {
|
|
2073
|
+
exec("rmux", [
|
|
2074
|
+
"kill-pane",
|
|
2075
|
+
"-t",
|
|
2076
|
+
target.id
|
|
2077
|
+
]);
|
|
2078
|
+
},
|
|
2079
|
+
paneExists(exec, target) {
|
|
2080
|
+
if (exec("rmux", [
|
|
2081
|
+
"has-session",
|
|
2082
|
+
"-t",
|
|
2083
|
+
target.id
|
|
2084
|
+
]) !== null) return true;
|
|
2085
|
+
return (exec("rmux", [
|
|
2086
|
+
"list-panes",
|
|
2087
|
+
"-a",
|
|
2088
|
+
"-F",
|
|
2089
|
+
"#{pane_id}"
|
|
2090
|
+
]) ?? "").split("\n").includes(target.id);
|
|
2091
|
+
},
|
|
2092
|
+
isPaneFocused(exec, target) {
|
|
2093
|
+
const out = exec("rmux", [
|
|
2094
|
+
"list-panes",
|
|
2095
|
+
"-a",
|
|
2096
|
+
"-F",
|
|
2097
|
+
"#{pane_id} #{pane_active} #{window_active} #{session_attached}"
|
|
2098
|
+
]);
|
|
2099
|
+
if (!out) return void 0;
|
|
2100
|
+
const line = out.split("\n").find((l) => l.split(" ")[0] === target.id);
|
|
2101
|
+
if (!line) return void 0;
|
|
2102
|
+
const [, paneActive, windowActive, sessionAttached] = line.split(" ");
|
|
2103
|
+
return paneActive === "1" && windowActive === "1" && sessionAttached !== "0" && sessionAttached !== void 0;
|
|
2104
|
+
},
|
|
2105
|
+
/**
|
|
2106
|
+
* Tab-separated, not space — the same rule `describeRmuxRegion` follows, and for the same reason:
|
|
2107
|
+
* `pane_current_path` and `pane_title` can both contain spaces, so a space-separated format makes
|
|
2108
|
+
* both fields unrecoverable (`my worker` and `/repo/my dir` cannot be told apart by a space). A tab
|
|
2109
|
+
* can appear in neither id nor command, and the two free-text fields are separated by one.
|
|
2110
|
+
*/
|
|
2111
|
+
listPanes(exec) {
|
|
2112
|
+
const out = exec("rmux", [
|
|
2113
|
+
"list-panes",
|
|
2114
|
+
"-a",
|
|
2115
|
+
"-F",
|
|
2116
|
+
"#{pane_id} #{pane_current_command} #{pane_current_path} #{pane_title} #{host}"
|
|
2117
|
+
]);
|
|
2118
|
+
if (!out) return [];
|
|
2119
|
+
return out.split("\n").filter(Boolean).map((line) => {
|
|
2120
|
+
const [id, , cwd, title, host] = line.split(" ");
|
|
2121
|
+
const pane = {
|
|
2122
|
+
id: id ?? "",
|
|
2123
|
+
mux: "rmux",
|
|
2124
|
+
floating: false
|
|
2125
|
+
};
|
|
2126
|
+
if (cwd) pane.cwd = cwd;
|
|
2127
|
+
const label = paneLabel$1(title, host);
|
|
2128
|
+
if (label) pane.label = label;
|
|
2129
|
+
return pane;
|
|
2130
|
+
}).filter((p) => p.id !== "");
|
|
2131
|
+
},
|
|
2132
|
+
regions: {
|
|
2133
|
+
describeRegion(exec, target) {
|
|
2134
|
+
return describeRmuxRegion(exec, target.id);
|
|
2135
|
+
},
|
|
2136
|
+
/**
|
|
2137
|
+
* `resize-pane -x/-y` in CELLS, never `-x <percent>`. rmux reads a percentage against the WINDOW
|
|
2138
|
+
* exactly as tmux does, while the seam's `ratio` is a fraction of the pane's own SPLIT REGION —
|
|
2139
|
+
* the same number only in a two-pane window, so a nested layout would be sized against the wrong
|
|
2140
|
+
* denominator. PROBED rather than inherited from the tmux adapter, because that is this backend's
|
|
2141
|
+
* rule (see the header): on rmux 0.10.0 in a 200-column window, `-x 60%` produced a 120-column
|
|
2142
|
+
* pane — 60% of the window — with the region's own split at a different fraction entirely.
|
|
2143
|
+
*
|
|
2144
|
+
* Verified against a live rmux 0.10.0 (`mux.rmux.integration.test.ts`): `-x` sizes the target pane
|
|
2145
|
+
* and its sibling absorbs the difference, `-y` does the same on a stacked split, and a resize
|
|
2146
|
+
* inside a nested region leaves the outer divider where it was — every case matching tmux 3.7c's
|
|
2147
|
+
* behavior number for number, which is what makes the shared `toRmuxResizeCells` arithmetic honest
|
|
2148
|
+
* rather than assumed.
|
|
2149
|
+
*/
|
|
2150
|
+
resizePane(exec, target, ratio) {
|
|
2151
|
+
const split = enclosingSplit(describeRmuxRegion(exec, target.id), target.id);
|
|
2152
|
+
if (!split) throw new Error(`rmux pane ${target.id} is the only pane in its region — there is no split to resize`);
|
|
2153
|
+
const flag = split.direction === "right" ? "-x" : "-y";
|
|
2154
|
+
if (exec("rmux", [
|
|
2155
|
+
"resize-pane",
|
|
2156
|
+
"-t",
|
|
2157
|
+
target.id,
|
|
2158
|
+
flag,
|
|
2159
|
+
String(toRmuxResizeCells(split, ratio))
|
|
2160
|
+
]) === null) throw new Error(withReason(exec, `rmux could not resize pane ${target.id}`));
|
|
2161
|
+
},
|
|
2162
|
+
/**
|
|
2163
|
+
* rmux has NO workspace tier — `workspace` and `tab` both collapse onto a Window — so a workspace
|
|
2164
|
+
* is not a fact this backend holds. What it holds is the grouping TAG the walk wrote
|
|
2165
|
+
* (`MuxOpenOptions.workspaceGroup`, stored in a window user option), so the read here is
|
|
2166
|
+
* literally *"which windows carry this group id"*.
|
|
2167
|
+
*
|
|
2168
|
+
* The tag, never the label. `list-windows -a` spans SESSIONS, so a bare name match would
|
|
2169
|
+
* over-collect a same-named window from another session, and taking the workspace off a
|
|
2170
|
+
* `<workspace> - <tab>` label is unsound in the first place (`acme - beta - main` splits two ways,
|
|
2171
|
+
* both legal). `-f '#{==:#{@cm_ws},<id>}'` keys on what actually identifies the group, filtered
|
|
2172
|
+
* server-side — verified live on rmux 0.10.0, which is the half a docs read could not settle.
|
|
2173
|
+
*
|
|
2174
|
+
* A window with NO tag is a workspace of ONE: the honest answer for a window nobody grouped, and
|
|
2175
|
+
* it costs no further call — the caller's own window is the whole workspace.
|
|
2176
|
+
*/
|
|
2177
|
+
describeWorkspace(exec, target) {
|
|
2178
|
+
const out = exec("rmux", [
|
|
2179
|
+
"display-message",
|
|
2180
|
+
"-p",
|
|
2181
|
+
"-t",
|
|
2182
|
+
target.id,
|
|
2183
|
+
`#{window_id}\t#{${RMUX_WORKSPACE_GROUP_OPTION}}\t#{${RMUX_TAB_NAME_OPTION}}\t#{window_name}`
|
|
2184
|
+
]);
|
|
2185
|
+
if (!out) throw new Error(withReason(exec, `rmux could not resolve the workspace around pane ${target.id}`));
|
|
2186
|
+
const [windowId, group, ownName, ...nameParts] = out.split("\n")[0].split(" ");
|
|
2187
|
+
if (!windowId) throw new Error(`rmux did not report the window around pane ${target.id}`);
|
|
2188
|
+
if (!group) return [rmuxTab(exec, windowId, ownName, nameParts.join(" "))];
|
|
2189
|
+
const listed = exec("rmux", [
|
|
2190
|
+
"list-windows",
|
|
2191
|
+
"-a",
|
|
2192
|
+
"-F",
|
|
2193
|
+
`#{window_id}\t#{${RMUX_TAB_NAME_OPTION}}\t#{window_name}`,
|
|
2194
|
+
"-f",
|
|
2195
|
+
`#{==:#{${RMUX_WORKSPACE_GROUP_OPTION}},${group}}`
|
|
2196
|
+
]);
|
|
2197
|
+
if (!listed) throw new Error(withReason(exec, `rmux could not enumerate the windows grouped as ${group}`));
|
|
2198
|
+
const tabs = listed.split("\n").filter(Boolean).map((line) => line.split(" ")).filter(([id]) => Boolean(id)).map(([id, own, ...rest]) => rmuxTab(exec, id, own, rest.join(" ")));
|
|
2199
|
+
if (tabs.length === 0) throw new Error(`rmux reported no windows grouped as ${group}`);
|
|
2200
|
+
return tabs;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
/**
|
|
2205
|
+
* One window, read as a tab: its id, the tab's OWN name, and its region's geometry.
|
|
2206
|
+
*
|
|
2207
|
+
* `ownName` is what `group` stored (`RMUX_TAB_NAME_OPTION`) and it WINS, because `windowName` is the
|
|
2208
|
+
* display name — on a grouped window that is the composed `pool - editor`, whose `editor` rmux's
|
|
2209
|
+
* single name field no longer holds. Reporting the display name instead would compound the prefix on
|
|
2210
|
+
* every capture/apply round trip (`pool - pool - editor`), and splitting it back apart is the unsound
|
|
2211
|
+
* parse the option exists to refuse.
|
|
2212
|
+
*
|
|
2213
|
+
* The window name is the FALLBACK, not a second guess: a window carrying no stored name is one nobody
|
|
2214
|
+
* composed a display name for, so its name already IS its own name.
|
|
2215
|
+
*/
|
|
2216
|
+
function rmuxTab(exec, windowId, ownName, windowName) {
|
|
2217
|
+
const tab = {
|
|
2218
|
+
id: windowId,
|
|
2219
|
+
panes: describeRmuxRegion(exec, windowId)
|
|
2220
|
+
};
|
|
2221
|
+
const label = ownName || windowName;
|
|
2222
|
+
if (label) tab.label = label;
|
|
2223
|
+
return tab;
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Every pane of the region `id` names, with its rectangle. `id` is a pane id (that pane's own window)
|
|
2227
|
+
* or a window id (that window) — `list-panes -t` resolves both, which is what lets the region read and
|
|
2228
|
+
* the workspace read share one query instead of two that could drift apart.
|
|
2229
|
+
*
|
|
2230
|
+
* `-t` scopes `list-panes` to ONE window — the region tier, which is what capture captures. Without
|
|
2231
|
+
* `-a`, so this never reaches the panes of some other window.
|
|
2232
|
+
*
|
|
2233
|
+
* `#{pane_left}`/`#{pane_top}` are window-relative, and the widths exclude the divider column rmux
|
|
2234
|
+
* draws between panes — both verified live on 0.10.0, where an 80x24 window split side by side
|
|
2235
|
+
* reported `%0 0 0 39 24` and `%1 40 0 40 24` (39 + 40 = 79, not 80). That is exactly what
|
|
2236
|
+
* `RegionPane.rect` documents, so nothing is adjusted here. The check is not a formality: a backend
|
|
2237
|
+
* that INCLUDED the divider would silently shift every rect a captured template stores.
|
|
2238
|
+
*
|
|
2239
|
+
* Tab-separated, not space: `pane_current_path` and `pane_title` can both contain spaces, and
|
|
2240
|
+
* splitting a path on spaces is how a directory with one in it silently becomes the wrong pane.
|
|
2241
|
+
*/
|
|
2242
|
+
function describeRmuxRegion(exec, id) {
|
|
2243
|
+
const out = exec("rmux", [
|
|
2244
|
+
"list-panes",
|
|
2245
|
+
"-t",
|
|
2246
|
+
id,
|
|
2247
|
+
"-F",
|
|
2248
|
+
"#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_current_path} #{pane_title} #{host}"
|
|
2249
|
+
]);
|
|
2250
|
+
if (!out) throw new Error(withReason(exec, `rmux could not describe the region around pane ${id}`));
|
|
2251
|
+
const panes = [];
|
|
2252
|
+
for (const line of out.split("\n").filter(Boolean)) {
|
|
2253
|
+
const [paneId, left, top, width, height, cwd, title, host] = line.split(" ");
|
|
2254
|
+
if (!paneId) continue;
|
|
2255
|
+
const pane = {
|
|
2256
|
+
id: paneId,
|
|
2257
|
+
rect: {
|
|
2258
|
+
x: Number(left),
|
|
2259
|
+
y: Number(top),
|
|
2260
|
+
width: Number(width),
|
|
2261
|
+
height: Number(height)
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
if (cwd) pane.cwd = cwd;
|
|
2265
|
+
const label = paneLabel$1(title, host);
|
|
2266
|
+
if (label) pane.label = label;
|
|
2267
|
+
panes.push(pane);
|
|
2268
|
+
}
|
|
2269
|
+
if (panes.length === 0) throw new Error(`rmux reported no panes in the region around pane ${id}`);
|
|
2270
|
+
return panes;
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* An rmux pane's label — its title, unless that title is the hostname rmux handed it.
|
|
2274
|
+
*
|
|
2275
|
+
* **rmux has no "unset title"**, exactly as tmux has none: it defaults `pane_title` to the hostname
|
|
2276
|
+
* (verified live on 0.10.0 — every pane of a fresh session reported the machine's hostname, and
|
|
2277
|
+
* `#{host}` reported the same string). Exporting that would label them all alike, and that label
|
|
2278
|
+
* would then resolve to every pane in the session — ambiguity manufactured out of nothing. A title
|
|
2279
|
+
* that differs from the host is one someone set (cyber-mux's own `select-pane -T` among them), so it
|
|
2280
|
+
* is the author's and survives.
|
|
2281
|
+
*
|
|
2282
|
+
* One home for the rule, called by BOTH reads — `listPanes` (which a name resolves against) and
|
|
2283
|
+
* `describeRmuxRegion` (which a capture exports). Two spellings of a heuristic this load-bearing is
|
|
2284
|
+
* how the listing and the capture come to disagree about which panes are named.
|
|
2285
|
+
*/
|
|
2286
|
+
function paneLabel$1(title, host) {
|
|
2287
|
+
return title && title !== host ? title : void 0;
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* The `-P -F '#{pane_id}\t#{window_id}'` report EVERY open asks for, split back into its two ids.
|
|
2291
|
+
* Tab-separated because neither id can contain a tab.
|
|
2292
|
+
*
|
|
2293
|
+
* A report that does not carry both throws rather than returning half an answer: the window is the
|
|
2294
|
+
* pane's tab, which `OpenedPane.tab` promises is always present, and it is also what a grouping open
|
|
2295
|
+
* tags. Guessing either would be worse than failing — a caller would name or group nothing and never
|
|
2296
|
+
* learn it.
|
|
2297
|
+
*/
|
|
2298
|
+
function splitOpenReport$1(out, command) {
|
|
2299
|
+
const [pane, windowId] = out.split(" ");
|
|
2300
|
+
if (!pane || !windowId) throw new Error(`rmux ${command} did not report the new pane's id and window id`);
|
|
2301
|
+
return [pane, windowId];
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* The seam's `ratio` as the CELL count `resize-pane -x/-y` wants for the target pane — rmux's copy of
|
|
2305
|
+
* the tmux adapter's `toTmuxResizeCells`, for the header's reason.
|
|
2306
|
+
*
|
|
2307
|
+
* Computed through the SECOND side rather than the target directly, because that is the definition
|
|
2308
|
+
* `ratioOf` (`region-tree.ts`) reads a live split back with, and a write that used the other formula
|
|
2309
|
+
* would not round-trip: rmux eats a column for the divider exactly as tmux does (probed on 0.10.0 —
|
|
2310
|
+
* a 200-column window splits into 119 + 80), so `first / (first + second)` and `1 - second / total`
|
|
2311
|
+
* disagree by that column. The divider is MEASURED rather than assumed to be one, so nothing here
|
|
2312
|
+
* depends on that probe staying true.
|
|
2313
|
+
*/
|
|
2314
|
+
function toRmuxResizeCells(split, ratio) {
|
|
2315
|
+
assertRatioInRange(ratio);
|
|
2316
|
+
const second = Math.round((split.targetIsFirst ? 1 - ratio : ratio) * split.extent);
|
|
2317
|
+
if (!split.targetIsFirst) return second;
|
|
2318
|
+
const divider = split.extent - split.targetExtent - split.otherExtent;
|
|
2319
|
+
return split.extent - divider - second;
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* `ratio` is the fraction kept by the ORIGINAL pane; rmux's `-l` sizes the NEW one, exactly as tmux's
|
|
2323
|
+
* does. So this INVERTS — `1 - ratio`. The direction was PROBED rather than inherited from the tmux
|
|
2324
|
+
* adapter: on rmux 0.10.0, `split-window -h -l 30%` in an 80-column window left the original at 55
|
|
2325
|
+
* columns and gave the new pane 24, which is the new pane taking the 30%. Reading the inversion off
|
|
2326
|
+
* the sibling adapter is how a shared-by-assumption number comes out silently backwards — a 0.333
|
|
2327
|
+
* template would size the original pane at 67%.
|
|
2328
|
+
*
|
|
2329
|
+
* Percent rather than cells: rmux takes `-l` as either, and a percentage is the only form that means
|
|
2330
|
+
* the same thing without first querying the region's size.
|
|
2331
|
+
*/
|
|
2332
|
+
function toRmuxSize(ratio) {
|
|
2333
|
+
assertRatioInRange(ratio);
|
|
2334
|
+
return `${Math.round((1 - ratio) * 100)}%`;
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* The core vocabulary's rmux spelling. Exactly one member differs, and it was PROBED against the live
|
|
2338
|
+
* binary rather than inherited from `TMUX_KEY_RENAMES`: rmux has no `Backspace` key name, so it falls
|
|
2339
|
+
* through to tmux's unrecognized-token behavior and TYPES the word. Verified on 0.10.0 —
|
|
2340
|
+
* `send-keys 'echo DEFX'` then `send-keys Backspace` produced the line `echo DEFXBackspace`, while
|
|
2341
|
+
* the same sequence with `BSpace` produced `echo DEF`. So rmux's name for that key is `BSpace`, and
|
|
2342
|
+
* the rename is real behavior on this binary rather than a claim carried over from tmux(1).
|
|
2343
|
+
*
|
|
2344
|
+
* Deliberately a rename table, NOT a validation table: a token outside the core is forwarded
|
|
2345
|
+
* verbatim (the contract), so this must not reject what it does not recognize. Keeping a full rmux
|
|
2346
|
+
* key list here would make the passthrough a second vocabulary to maintain.
|
|
2347
|
+
*/
|
|
2348
|
+
const RMUX_KEY_RENAMES = { Backspace: "BSpace" };
|
|
2349
|
+
/**
|
|
2350
|
+
* One spelling of the capture, taken by `read` for the snapshot AND for the one-row-deeper truncation
|
|
2351
|
+
* probe — so the two differ in exactly the number they disagree about and nothing else. `lines`
|
|
2352
|
+
* omitted is rmux's own default window: the visible screen, with no `-S` at all.
|
|
2353
|
+
*/
|
|
2354
|
+
function capturePane$1(exec, target, lines) {
|
|
2355
|
+
const args = [
|
|
2356
|
+
"capture-pane",
|
|
2357
|
+
"-p",
|
|
2358
|
+
"-t",
|
|
2359
|
+
target.id
|
|
2360
|
+
];
|
|
2361
|
+
if (lines === "all") args.push("-S", "-");
|
|
2362
|
+
else if (lines != null) args.push("-S", `-${lines}`);
|
|
2363
|
+
return exec("rmux", args) ?? "";
|
|
2364
|
+
}
|
|
2365
|
+
function toRmuxKey(key) {
|
|
2366
|
+
return RMUX_KEY_RENAMES[key] ?? key;
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* `rmux list-panes -a -F '#{pane_id} #{session_name} #{window_id}'` lists every pane server-wide.
|
|
2370
|
+
* Resolving fails — no line's pane id matches `id` — when the pane no longer exists in the backend,
|
|
2371
|
+
* and that must throw so `focus` never issues a switch-client/select-window against a pane it
|
|
2372
|
+
* couldn't actually resolve.
|
|
2373
|
+
*/
|
|
2374
|
+
function parsePaneLocation$1(out, id) {
|
|
2375
|
+
const line = (out ?? "").split("\n").find((l) => l.split(" ")[0] === id);
|
|
2376
|
+
if (!line) throw new Error(`peer's pane ${id} could not be resolved to beam to`);
|
|
2377
|
+
const [, sessionName, windowId] = line.split(" ");
|
|
2378
|
+
return {
|
|
2379
|
+
sessionName,
|
|
2380
|
+
windowId
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
//#endregion
|
|
2384
|
+
//#region src/mux.tmux.ts
|
|
2385
|
+
/**
|
|
2386
|
+
* The tmux window user option `MuxOpenOptions.workspaceGroup` is stored in. A user option (the
|
|
2387
|
+
* `@` prefix) is tmux's own mechanism for a value it stores but never interprets, so tmux carries
|
|
2388
|
+
* the tag without cyber-mux teaching it anything: it survives a window rename, and `list-windows`
|
|
2389
|
+
* both reads it back (`#{@cm_ws}`) and filters on it server-side (`-f '#{==:#{@cm_ws},<id>}'`).
|
|
2390
|
+
*
|
|
2391
|
+
* Named here rather than spelled at each use so the write side and every read side cannot drift.
|
|
2392
|
+
* Server-lifetime, like every window: it dies with the tmux server, along with the windows it tags.
|
|
2393
|
+
*/
|
|
2394
|
+
const TMUX_WORKSPACE_GROUP_OPTION = "@cm_ws";
|
|
2395
|
+
/**
|
|
2396
|
+
* The tmux window user option a grouped window's OWN name is stored in — the name the caller gave the
|
|
2397
|
+
* tab, beside the group id, because tmux's single `window_name` field no longer holds it.
|
|
2398
|
+
*
|
|
2399
|
+
* tmux has ONE name field per space. A caller that composes a display name out of a tab's name
|
|
2400
|
+
* (`pool - editor`) has destroyed `editor`, and there is no sound way back: splitting on the separator
|
|
2401
|
+
* is ambiguous (`acme - beta - main` reads two legal ways), and reading the display name verbatim
|
|
2402
|
+
* re-prefixes it on every round trip (`pool - pool - editor`). So the original is stored here and read
|
|
2403
|
+
* back from here — the same rule the group id follows, one tier down. The display name is a human's to
|
|
2404
|
+
* read; this is what a machine reads.
|
|
2405
|
+
*
|
|
2406
|
+
* A user option (the `@` prefix) for `TMUX_WORKSPACE_GROUP_OPTION`'s reasons exactly: tmux stores it
|
|
2407
|
+
* without interpreting it, it survives a window rename, and `list-windows` reads it back
|
|
2408
|
+
* (`#{@cm_tab}`). Named here rather than spelled at each use so the write side and every read side
|
|
2409
|
+
* cannot drift.
|
|
2410
|
+
*/
|
|
2411
|
+
const TMUX_TAB_NAME_OPTION = "@cm_tab";
|
|
2412
|
+
/** tmux backend — detected via `$TMUX`. */
|
|
2413
|
+
const tmuxMuxAdapter = {
|
|
2414
|
+
name: "tmux",
|
|
2415
|
+
canSizeSplits: true,
|
|
2416
|
+
/**
|
|
2417
|
+
* `new-pane` opens a floating pane — tmux 3.7's own new command ("Add floating panes. These are
|
|
2418
|
+
* panes which sit above the layout ('tiled panes') like popups but unlike popups are not modal and
|
|
2419
|
+
* behave like panes", CHANGES 3.6b → 3.7), bound to `*` by default.
|
|
2420
|
+
*
|
|
2421
|
+
* Declared UNCONDITIONALLY rather than probed off `tmux -V`, and that is deliberate: this adapter
|
|
2422
|
+
* takes no version reading anywhere (its `-l`, `-e` and `@`-option paths are all declared the same
|
|
2423
|
+
* way), and a version probe would cost an exec on every resolution to pre-empt a failure tmux
|
|
2424
|
+
* already reports precisely. On tmux ≤ 3.6 the command does not exist and `new-pane` fails with
|
|
2425
|
+
* tmux's own `unknown command` — surfaced by the `withReason` throw in `open`, which names the
|
|
2426
|
+
* command that failed. A silent wrong-pane is the failure mode worth engineering against, and this
|
|
2427
|
+
* has none: there is nothing for an absent `new-pane` to be mistaken for.
|
|
2428
|
+
*
|
|
2429
|
+
* Both sides of this placement are now pinned against a real 3.7c binary — the read side by
|
|
2430
|
+
* `#{pane_floating_flag}`, the create side by the `new-pane` rows in `mux.tmux.integration.test.ts`.
|
|
2431
|
+
* The branch below was originally written off tmux's CHANGES file, because the tmux installed when
|
|
2432
|
+
* it landed was 3.6b and had no `new-pane` to run it against.
|
|
2433
|
+
*/
|
|
2434
|
+
canFloatPanes: true,
|
|
2435
|
+
/**
|
|
2436
|
+
* Every route passes `-d`, so no open moves the attached client. tmux is the backend where this
|
|
2437
|
+
* cost the most to make true: `-d` was already on `new-window`, but `split-window` and `new-pane`
|
|
2438
|
+
* were issuing it nowhere, and both ACTIVATE what they create. Measured on 3.7c rather than read
|
|
2439
|
+
* off the man page — a bare `split-window` in a session focused on %0 left %1 active, and a bare
|
|
2440
|
+
* `new-pane` did the same for the float; with `-d` each left focus on %0 and still printed the new
|
|
2441
|
+
* id through `-P -F`. So the flag costs the id report nothing, which is what makes it free to add.
|
|
2442
|
+
*
|
|
2443
|
+
* There is no focus move to undo on top of that, unlike zellij: `-t` targets the pane to split
|
|
2444
|
+
* directly, so tmux never has to VISIT a pane to choose it.
|
|
2445
|
+
*/
|
|
2446
|
+
opensWithoutStealingFocus: true,
|
|
2447
|
+
open(exec, opts) {
|
|
2448
|
+
const at = opts.at ?? "tab";
|
|
2449
|
+
const window = at === "workspace" || at === "tab";
|
|
2450
|
+
const env = opts.env ? Object.entries(opts.env).flatMap(([k, v]) => ["-e", `${k}=${v}`]) : [];
|
|
2451
|
+
const group = window && opts.workspaceGroup != null;
|
|
2452
|
+
const format = "#{pane_id} #{window_id}";
|
|
2453
|
+
let args;
|
|
2454
|
+
if (at === "pane:float") args = [
|
|
2455
|
+
"new-pane",
|
|
2456
|
+
"-d",
|
|
2457
|
+
...opts.from ? ["-t", opts.from.id] : [],
|
|
2458
|
+
...env,
|
|
2459
|
+
"-c",
|
|
2460
|
+
opts.cwd,
|
|
2461
|
+
"-P",
|
|
2462
|
+
"-F",
|
|
2463
|
+
format
|
|
2464
|
+
];
|
|
2465
|
+
else if (window) args = [
|
|
2466
|
+
"new-window",
|
|
2467
|
+
"-d",
|
|
2468
|
+
...env,
|
|
2469
|
+
"-c",
|
|
2470
|
+
opts.cwd,
|
|
2471
|
+
"-P",
|
|
2472
|
+
"-F",
|
|
2473
|
+
format
|
|
2474
|
+
];
|
|
2475
|
+
else {
|
|
2476
|
+
const from = opts.from ? ["-t", opts.from.id] : [];
|
|
2477
|
+
const size = opts.ratio != null ? ["-l", toTmuxSize(opts.ratio)] : [];
|
|
2478
|
+
args = [
|
|
2479
|
+
"split-window",
|
|
2480
|
+
"-d",
|
|
2481
|
+
at === "pane:down" ? "-v" : "-h",
|
|
2482
|
+
...from,
|
|
2483
|
+
...size,
|
|
2484
|
+
...env,
|
|
2485
|
+
"-c",
|
|
2486
|
+
opts.cwd,
|
|
2487
|
+
"-P",
|
|
2488
|
+
"-F",
|
|
2489
|
+
format
|
|
2490
|
+
];
|
|
2491
|
+
}
|
|
2492
|
+
if (window && opts.label) args.splice(1, 0, "-n", opts.label);
|
|
2493
|
+
const out = exec("tmux", args);
|
|
2494
|
+
if (!out) throw new Error(withReason(exec, `tmux ${args[0]} failed`));
|
|
2495
|
+
const [pane, windowId] = splitOpenReport(out, args[0]);
|
|
2496
|
+
const target = {
|
|
2497
|
+
id: pane,
|
|
2498
|
+
tab: windowId
|
|
2499
|
+
};
|
|
2500
|
+
if (group && windowId) tmuxMuxAdapter.group(exec, { id: windowId }, opts.workspaceGroup);
|
|
2501
|
+
if (!window && opts.label) tmuxMuxAdapter.rename(exec, target, "pane", opts.label);
|
|
2502
|
+
if (opts.launch) tmuxMuxAdapter.submit(exec, target, opts.launch);
|
|
2503
|
+
return target;
|
|
2504
|
+
},
|
|
2505
|
+
rename(exec, target, tier, name) {
|
|
2506
|
+
if (tier === "tab") {
|
|
2507
|
+
exec("tmux", [
|
|
2508
|
+
"rename-window",
|
|
2509
|
+
"-t",
|
|
2510
|
+
target.id,
|
|
2511
|
+
name
|
|
2512
|
+
]);
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
exec("tmux", [
|
|
2516
|
+
"select-pane",
|
|
2517
|
+
"-t",
|
|
2518
|
+
target.id,
|
|
2519
|
+
"-T",
|
|
2520
|
+
name
|
|
2521
|
+
]);
|
|
2522
|
+
},
|
|
2523
|
+
group(exec, target, group, name) {
|
|
2524
|
+
exec("tmux", [
|
|
2525
|
+
"set-option",
|
|
2526
|
+
"-w",
|
|
2527
|
+
"-t",
|
|
2528
|
+
target.id,
|
|
2529
|
+
TMUX_WORKSPACE_GROUP_OPTION,
|
|
2530
|
+
group
|
|
2531
|
+
]);
|
|
2532
|
+
if (name !== void 0) exec("tmux", [
|
|
2533
|
+
"set-option",
|
|
2534
|
+
"-w",
|
|
2535
|
+
"-t",
|
|
2536
|
+
target.id,
|
|
2537
|
+
TMUX_TAB_NAME_OPTION,
|
|
2538
|
+
name
|
|
2539
|
+
]);
|
|
2540
|
+
},
|
|
2541
|
+
sendText(exec, target, text) {
|
|
2542
|
+
exec("tmux", [
|
|
2543
|
+
"send-keys",
|
|
2544
|
+
"-t",
|
|
2545
|
+
target.id,
|
|
2546
|
+
"-l",
|
|
2547
|
+
text
|
|
2548
|
+
]);
|
|
2549
|
+
},
|
|
2550
|
+
sendKeys(exec, target, keys) {
|
|
2551
|
+
exec("tmux", [
|
|
2552
|
+
"send-keys",
|
|
2553
|
+
"-t",
|
|
2554
|
+
target.id,
|
|
2555
|
+
...keys.map(toTmuxKey)
|
|
2556
|
+
]);
|
|
2557
|
+
},
|
|
2558
|
+
submit(exec, target, text) {
|
|
2559
|
+
if (!text) {
|
|
2560
|
+
exec("tmux", [
|
|
2561
|
+
"send-keys",
|
|
2562
|
+
"-t",
|
|
2563
|
+
target.id,
|
|
2564
|
+
"Enter"
|
|
2565
|
+
]);
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
tmuxMuxAdapter.sendText(exec, target, text);
|
|
2569
|
+
exec("tmux", [
|
|
2570
|
+
"send-keys",
|
|
2571
|
+
"-t",
|
|
2572
|
+
target.id,
|
|
2573
|
+
"Enter"
|
|
2574
|
+
]);
|
|
2575
|
+
},
|
|
2576
|
+
read(exec, target, opts) {
|
|
2577
|
+
const text = capturePane(exec, target, opts?.lines);
|
|
2578
|
+
if (!opts?.truncation) return { text };
|
|
2579
|
+
if (opts.lines === "all") return {
|
|
2580
|
+
text,
|
|
2581
|
+
truncated: false
|
|
2582
|
+
};
|
|
2583
|
+
return {
|
|
2584
|
+
text,
|
|
2585
|
+
truncated: isReadTruncated(text, capturePane(exec, target, (opts.lines ?? 0) + 1))
|
|
2586
|
+
};
|
|
2587
|
+
},
|
|
2588
|
+
waitForOutput(exec, target, opts) {
|
|
2589
|
+
return pollForOutput(tmuxMuxAdapter, exec, target, opts);
|
|
2590
|
+
},
|
|
2591
|
+
focus(exec, target) {
|
|
2592
|
+
const { sessionName, windowId } = parsePaneLocation(exec("tmux", [
|
|
2593
|
+
"list-panes",
|
|
2594
|
+
"-a",
|
|
2595
|
+
"-F",
|
|
2596
|
+
"#{pane_id} #{session_name} #{window_id}"
|
|
2597
|
+
]), target.id);
|
|
2598
|
+
exec("tmux", [
|
|
2599
|
+
"switch-client",
|
|
2600
|
+
"-t",
|
|
2601
|
+
sessionName
|
|
2602
|
+
]);
|
|
2603
|
+
exec("tmux", [
|
|
2604
|
+
"select-window",
|
|
2605
|
+
"-t",
|
|
2606
|
+
windowId
|
|
2607
|
+
]);
|
|
2608
|
+
exec("tmux", [
|
|
2609
|
+
"select-pane",
|
|
2610
|
+
"-t",
|
|
2611
|
+
target.id
|
|
2612
|
+
]);
|
|
2613
|
+
},
|
|
2614
|
+
teardown(exec, target) {
|
|
2615
|
+
exec("tmux", [
|
|
2616
|
+
"kill-pane",
|
|
2617
|
+
"-t",
|
|
2618
|
+
target.id
|
|
2619
|
+
]);
|
|
2620
|
+
},
|
|
2621
|
+
paneExists(exec, target) {
|
|
2622
|
+
if (exec("tmux", [
|
|
2623
|
+
"has-session",
|
|
2624
|
+
"-t",
|
|
2625
|
+
target.id
|
|
2626
|
+
]) !== null) return true;
|
|
2627
|
+
return (exec("tmux", [
|
|
2628
|
+
"list-panes",
|
|
2629
|
+
"-a",
|
|
2630
|
+
"-F",
|
|
2631
|
+
"#{pane_id}"
|
|
2632
|
+
]) ?? "").split("\n").includes(target.id);
|
|
2633
|
+
},
|
|
2634
|
+
isPaneFocused(exec, target) {
|
|
2635
|
+
const out = exec("tmux", [
|
|
2636
|
+
"list-panes",
|
|
2637
|
+
"-a",
|
|
2638
|
+
"-F",
|
|
2639
|
+
"#{pane_id} #{pane_active} #{window_active} #{session_attached}"
|
|
2640
|
+
]);
|
|
2641
|
+
if (!out) return void 0;
|
|
2642
|
+
const line = out.split("\n").find((l) => l.split(" ")[0] === target.id);
|
|
2643
|
+
if (!line) return void 0;
|
|
2644
|
+
const [, paneActive, windowActive, sessionAttached] = line.split(" ");
|
|
2645
|
+
return paneActive === "1" && windowActive === "1" && sessionAttached !== "0" && sessionAttached !== void 0;
|
|
2646
|
+
},
|
|
2647
|
+
/**
|
|
2648
|
+
* Tab-separated, not space — the same rule `describeTmuxRegion` follows, and for the same reason:
|
|
2649
|
+
* `pane_current_path` and `pane_title` can both contain spaces. The old space-separated format
|
|
2650
|
+
* recovered the cwd by rejoining everything after the command, which works only while the cwd is
|
|
2651
|
+
* the LAST field. A label is a human's and may hold anything, so appending one to that format would
|
|
2652
|
+
* make both fields unrecoverable — `my worker` and `/repo/my dir` cannot be told apart by a space.
|
|
2653
|
+
* A tab can appear in neither id nor command, and the two free-text fields are separated by one.
|
|
2654
|
+
*/
|
|
2655
|
+
listPanes(exec) {
|
|
2656
|
+
const out = exec("tmux", [
|
|
2657
|
+
"list-panes",
|
|
2658
|
+
"-a",
|
|
2659
|
+
"-F",
|
|
2660
|
+
"#{pane_id} #{pane_current_command} #{pane_current_path} #{pane_title} #{host} #{pane_floating_flag}"
|
|
2661
|
+
]);
|
|
2662
|
+
if (!out) return [];
|
|
2663
|
+
return out.split("\n").filter(Boolean).map((line) => {
|
|
2664
|
+
const [id, , cwd, title, host, floating] = line.split(" ");
|
|
2665
|
+
const pane = {
|
|
2666
|
+
id: id ?? "",
|
|
2667
|
+
mux: "tmux",
|
|
2668
|
+
floating: floating === "1"
|
|
2669
|
+
};
|
|
2670
|
+
if (cwd) pane.cwd = cwd;
|
|
2671
|
+
const label = paneLabel(title, host);
|
|
2672
|
+
if (label) pane.label = label;
|
|
2673
|
+
return pane;
|
|
2674
|
+
}).filter((p) => p.id !== "");
|
|
2675
|
+
},
|
|
2676
|
+
regions: {
|
|
2677
|
+
describeRegion(exec, target) {
|
|
2678
|
+
return describeTmuxRegion(exec, target.id);
|
|
2679
|
+
},
|
|
2680
|
+
/**
|
|
2681
|
+
* `resize-pane -x/-y` in CELLS, never `-x <percent>`, and the difference is not cosmetic: tmux
|
|
2682
|
+
* takes a percentage of the WINDOW, while the seam's `ratio` is a fraction of the pane's own
|
|
2683
|
+
* SPLIT REGION. Those are the same number only in a two-pane window, so a nested layout would be
|
|
2684
|
+
* sized against the wrong denominator — silently, and only for the users with more than one
|
|
2685
|
+
* split. Cells computed from the rects tmux just reported are exact at every depth.
|
|
2686
|
+
*
|
|
2687
|
+
* Verified against a live tmux 3.7c (`mux.tmux.integration.test.ts`): `-x` sizes the target pane
|
|
2688
|
+
* and its sibling absorbs the difference, `-y` does the same on a stacked split, and a resize
|
|
2689
|
+
* inside a nested region leaves the outer divider where it was.
|
|
2690
|
+
*/
|
|
2691
|
+
resizePane(exec, target, ratio) {
|
|
2692
|
+
const split = enclosingSplit(describeTmuxRegion(exec, target.id), target.id);
|
|
2693
|
+
if (!split) throw new Error(`tmux pane ${target.id} is the only pane in its region — there is no split to resize`);
|
|
2694
|
+
const flag = split.direction === "right" ? "-x" : "-y";
|
|
2695
|
+
const cells = toTmuxResizeCells(split, ratio);
|
|
2696
|
+
if (exec("tmux", [
|
|
2697
|
+
"resize-pane",
|
|
2698
|
+
"-t",
|
|
2699
|
+
target.id,
|
|
2700
|
+
flag,
|
|
2701
|
+
String(cells)
|
|
2702
|
+
]) === null) throw new Error(withReason(exec, `tmux could not resize pane ${target.id}`));
|
|
2703
|
+
},
|
|
2704
|
+
/**
|
|
2705
|
+
* tmux has NO workspace tier — `workspace` and `tab` both collapse onto a Window — so a workspace
|
|
2706
|
+
* is not a fact this backend holds. What it holds is the grouping TAG the walk wrote
|
|
2707
|
+
* (`MuxOpenOptions.workspaceGroup`, stored in a window user option), so the read here is
|
|
2708
|
+
* literally *"which windows carry this group id"*.
|
|
2709
|
+
*
|
|
2710
|
+
* The tag, never the label. `list-windows -a` spans SESSIONS, so a bare name match would
|
|
2711
|
+
* over-collect a same-named window from another session, and taking the workspace off a
|
|
2712
|
+
* `<workspace> - <tab>` label is unsound in the first place (`acme - beta - main` splits two ways,
|
|
2713
|
+
* both legal). `-f '#{==:#{@cm_ws},<id>}'` keys on what actually identifies the group, filtered
|
|
2714
|
+
* server-side — the tag survives a window rename, which a name-encoded grouping does not.
|
|
2715
|
+
*
|
|
2716
|
+
* A window with NO tag is a workspace of ONE: the honest answer for a window nobody grouped, and
|
|
2717
|
+
* it costs no further call — the caller's own window is the whole workspace.
|
|
2718
|
+
*/
|
|
2719
|
+
describeWorkspace(exec, target) {
|
|
2720
|
+
const out = exec("tmux", [
|
|
2721
|
+
"display-message",
|
|
2722
|
+
"-p",
|
|
2723
|
+
"-t",
|
|
2724
|
+
target.id,
|
|
2725
|
+
`#{window_id}\t#{${TMUX_WORKSPACE_GROUP_OPTION}}\t#{${TMUX_TAB_NAME_OPTION}}\t#{window_name}`
|
|
2726
|
+
]);
|
|
2727
|
+
if (!out) throw new Error(withReason(exec, `tmux could not resolve the workspace around pane ${target.id}`));
|
|
2728
|
+
const [windowId, group, ownName, ...nameParts] = out.split("\n")[0].split(" ");
|
|
2729
|
+
if (!windowId) throw new Error(`tmux did not report the window around pane ${target.id}`);
|
|
2730
|
+
if (!group) return [tmuxTab(exec, windowId, ownName, nameParts.join(" "))];
|
|
2731
|
+
const listed = exec("tmux", [
|
|
2732
|
+
"list-windows",
|
|
2733
|
+
"-a",
|
|
2734
|
+
"-F",
|
|
2735
|
+
`#{window_id}\t#{${TMUX_TAB_NAME_OPTION}}\t#{window_name}`,
|
|
2736
|
+
"-f",
|
|
2737
|
+
`#{==:#{${TMUX_WORKSPACE_GROUP_OPTION}},${group}}`
|
|
2738
|
+
]);
|
|
2739
|
+
if (!listed) throw new Error(withReason(exec, `tmux could not enumerate the windows grouped as ${group}`));
|
|
2740
|
+
const tabs = listed.split("\n").filter(Boolean).map((line) => line.split(" ")).filter(([id]) => Boolean(id)).map(([id, own, ...rest]) => tmuxTab(exec, id, own, rest.join(" ")));
|
|
2741
|
+
if (tabs.length === 0) throw new Error(`tmux reported no windows grouped as ${group}`);
|
|
2742
|
+
return tabs;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
};
|
|
2746
|
+
/**
|
|
2747
|
+
* One window, read as a tab: its id, the tab's OWN name, and its region's geometry.
|
|
2748
|
+
*
|
|
2749
|
+
* `ownName` is what `group` stored (`TMUX_TAB_NAME_OPTION`) and it WINS, because `windowName` is the
|
|
2750
|
+
* display name — on a grouped window that is the composed `pool - editor`, whose `editor` tmux's
|
|
2751
|
+
* single name field no longer holds. Reporting the display name instead would compound the prefix on
|
|
2752
|
+
* every capture/apply round trip (`pool - pool - editor`), and splitting it back apart is the unsound
|
|
2753
|
+
* parse the option exists to refuse.
|
|
2754
|
+
*
|
|
2755
|
+
* The window name is the FALLBACK, not a second guess: a window carrying no stored name is one nobody
|
|
2756
|
+
* composed a display name for, so its name already IS its own name. That covers the untagged window —
|
|
2757
|
+
* a workspace of one — and any window a caller grouped without naming.
|
|
2758
|
+
*/
|
|
2759
|
+
function tmuxTab(exec, windowId, ownName, windowName) {
|
|
2760
|
+
const tab = {
|
|
2761
|
+
id: windowId,
|
|
2762
|
+
panes: describeTmuxRegion(exec, windowId)
|
|
2763
|
+
};
|
|
2764
|
+
const label = ownName || windowName;
|
|
2765
|
+
if (label) tab.label = label;
|
|
2766
|
+
return tab;
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Every pane of the region `id` names, with its rectangle. `id` is a pane id (that pane's own window)
|
|
2770
|
+
* or a window id (that window) — `list-panes -t` resolves both, which is what lets the region read and
|
|
2771
|
+
* the workspace read share one query instead of two that could drift apart.
|
|
2772
|
+
*
|
|
2773
|
+
* `-t` scopes `list-panes` to ONE window — the region tier, which is what capture captures. Without
|
|
2774
|
+
* `-a`, so this never reaches the panes of some other window.
|
|
2775
|
+
*
|
|
2776
|
+
* `#{pane_left}`/`#{pane_top}` are window-relative, and the widths exclude the divider column tmux
|
|
2777
|
+
* draws between panes (a 200-wide window split side by side reports 119 + 80, not 200) — both are
|
|
2778
|
+
* exactly what `RegionPane.rect` documents, so nothing is adjusted here.
|
|
2779
|
+
*
|
|
2780
|
+
* Tab-separated, not space: `pane_current_path` and `pane_title` can both contain spaces, and
|
|
2781
|
+
* splitting a path on spaces is how a directory with one in it silently becomes the wrong pane.
|
|
2782
|
+
*/
|
|
2783
|
+
function describeTmuxRegion(exec, id) {
|
|
2784
|
+
const out = exec("tmux", [
|
|
2785
|
+
"list-panes",
|
|
2786
|
+
"-t",
|
|
2787
|
+
id,
|
|
2788
|
+
"-F",
|
|
2789
|
+
"#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_current_path} #{pane_title} #{host}"
|
|
2790
|
+
]);
|
|
2791
|
+
if (!out) throw new Error(withReason(exec, `tmux could not describe the region around pane ${id}`));
|
|
2792
|
+
const panes = [];
|
|
2793
|
+
for (const line of out.split("\n").filter(Boolean)) {
|
|
2794
|
+
const [paneId, left, top, width, height, cwd, title, host] = line.split(" ");
|
|
2795
|
+
if (!paneId) continue;
|
|
2796
|
+
const pane = {
|
|
2797
|
+
id: paneId,
|
|
2798
|
+
rect: {
|
|
2799
|
+
x: Number(left),
|
|
2800
|
+
y: Number(top),
|
|
2801
|
+
width: Number(width),
|
|
2802
|
+
height: Number(height)
|
|
2803
|
+
}
|
|
2804
|
+
};
|
|
2805
|
+
if (cwd) pane.cwd = cwd;
|
|
2806
|
+
const label = paneLabel(title, host);
|
|
2807
|
+
if (label) pane.label = label;
|
|
2808
|
+
panes.push(pane);
|
|
2809
|
+
}
|
|
2810
|
+
if (panes.length === 0) throw new Error(`tmux reported no panes in the region around pane ${id}`);
|
|
2811
|
+
return panes;
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* A tmux pane's label — its title, unless that title is the hostname tmux handed it.
|
|
2815
|
+
*
|
|
2816
|
+
* **tmux has no "unset title"**: it defaults `pane_title` to the hostname, so a pane nobody ever named
|
|
2817
|
+
* reports a name nobody chose, and every pane in an untouched session reports the SAME one. Exporting
|
|
2818
|
+
* that would label them all `zeta`, and `zeta` would then resolve to every pane in the session —
|
|
2819
|
+
* ambiguity manufactured out of nothing. A title that differs from the host is one someone set
|
|
2820
|
+
* (cyber-mux's own `select-pane -T` among them), so it is the author's and survives.
|
|
2821
|
+
*
|
|
2822
|
+
* One home for the rule, called by BOTH reads — `listPanes` (which a name resolves against) and
|
|
2823
|
+
* `describeTmuxRegion` (which a capture exports). Two spellings of a heuristic this load-bearing is
|
|
2824
|
+
* how the listing and the capture come to disagree about which panes are named.
|
|
2825
|
+
*
|
|
2826
|
+
* The comparison is the workaround, not the shape of the thing: herdr has the honest primitive and
|
|
2827
|
+
* omits the key outright until a pane is renamed, so it needs no rule at all.
|
|
2828
|
+
*/
|
|
2829
|
+
function paneLabel(title, host) {
|
|
2830
|
+
return title && title !== host ? title : void 0;
|
|
2831
|
+
}
|
|
2832
|
+
/**
|
|
2833
|
+
* The `-P -F '#{pane_id}\t#{window_id}'` report EVERY open asks for, split back into its two ids.
|
|
2834
|
+
* Tab-separated because neither id can contain a tab.
|
|
2835
|
+
*
|
|
2836
|
+
* A report that does not carry both throws rather than returning half an answer: the window is the
|
|
2837
|
+
* pane's tab, which `OpenedPane.tab` promises is always present, and it is also what a grouping open
|
|
2838
|
+
* tags. Guessing either would be worse than failing — a caller would name or group nothing and never
|
|
2839
|
+
* learn it.
|
|
2840
|
+
*/
|
|
2841
|
+
function splitOpenReport(out, command) {
|
|
2842
|
+
const [pane, windowId] = out.split(" ");
|
|
2843
|
+
if (!pane || !windowId) throw new Error(`tmux ${command} did not report the new pane's id and window id`);
|
|
2844
|
+
return [pane, windowId];
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* The seam's `ratio` as the CELL count `resize-pane -x/-y` wants for the target pane.
|
|
2848
|
+
*
|
|
2849
|
+
* Computed through the SECOND side rather than the target directly, because that is the definition
|
|
2850
|
+
* `ratioOf` (`region-tree.ts`) reads a live split back with, and a write that used the other formula
|
|
2851
|
+
* would not round-trip: tmux eats a column for the divider, so `first / (first + second)` and
|
|
2852
|
+
* `1 - second / total` disagree by exactly that column. Going through the second side means a region
|
|
2853
|
+
* described at 0.6 and resized to 0.6 is a no-op, which is the property a caller restoring a drifted
|
|
2854
|
+
* layout depends on.
|
|
2855
|
+
*
|
|
2856
|
+
* The divider is MEASURED (`extent - targetExtent - otherExtent`) rather than assumed to be one
|
|
2857
|
+
* column: it is the same arithmetic on a backend that draws no divider, so nothing here is tmux-shaped
|
|
2858
|
+
* beyond the flag it renders into.
|
|
2859
|
+
*/
|
|
2860
|
+
function toTmuxResizeCells(split, ratio) {
|
|
2861
|
+
assertRatioInRange(ratio);
|
|
2862
|
+
const second = Math.round((split.targetIsFirst ? 1 - ratio : ratio) * split.extent);
|
|
2863
|
+
if (!split.targetIsFirst) return second;
|
|
2864
|
+
const divider = split.extent - split.targetExtent - split.otherExtent;
|
|
2865
|
+
return split.extent - divider - second;
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2868
|
+
* `ratio` is the fraction kept by the ORIGINAL pane; tmux's `-l` sizes the NEW one. So this INVERTS
|
|
2869
|
+
* — `1 - ratio` — where herdr's `--ratio` passes the same number through untouched. The two backends
|
|
2870
|
+
* genuinely convert in opposite directions, and applying the inversion to both (or to neither) is
|
|
2871
|
+
* the way this gets silently backwards: a 0.333 template would size the original pane at 67%.
|
|
2872
|
+
*
|
|
2873
|
+
* Percent rather than cells: tmux takes `-l` as either, and a percentage is the only form that means
|
|
2874
|
+
* the same thing without first querying the region's size.
|
|
2875
|
+
*/
|
|
2876
|
+
function toTmuxSize(ratio) {
|
|
2877
|
+
assertRatioInRange(ratio);
|
|
2878
|
+
return `${Math.round((1 - ratio) * 100)}%`;
|
|
2879
|
+
}
|
|
2880
|
+
/**
|
|
2881
|
+
* The core vocabulary's tmux spelling. Exactly one member differs — probed, not read off tmux(1):
|
|
2882
|
+
* tmux has no `Backspace` key name, so it would *type* the word (its unrecognized-token fallback);
|
|
2883
|
+
* its name for that key is `BSpace` (tmux(1): "the following special key names are accepted: Up,
|
|
2884
|
+
* Down, Left, Right, BSpace, BTab, DC ..."). Every other core key — `Up` `Down` `Left` `Right`
|
|
2885
|
+
* `Enter` `Escape` `Tab` `Space` `C-c` `F1`-`F12` — is already tmux's own name for it.
|
|
2886
|
+
*
|
|
2887
|
+
* Deliberately a rename table, NOT a validation table: a token outside the core is forwarded
|
|
2888
|
+
* verbatim (the contract), so this must not reject what it does not recognize. Keeping a full tmux
|
|
2889
|
+
* key list here would make the passthrough a second vocabulary to maintain.
|
|
2890
|
+
*/
|
|
2891
|
+
const TMUX_KEY_RENAMES = { Backspace: "BSpace" };
|
|
2892
|
+
/**
|
|
2893
|
+
* One spelling of the capture, taken by `read` for the snapshot AND for the one-row-deeper truncation
|
|
2894
|
+
* probe — so the two differ in exactly the number they disagree about and nothing else. `lines`
|
|
2895
|
+
* omitted is tmux's own default window: the visible screen, with no `-S` at all.
|
|
2896
|
+
*/
|
|
2897
|
+
function capturePane(exec, target, lines) {
|
|
2898
|
+
const args = [
|
|
2899
|
+
"capture-pane",
|
|
2900
|
+
"-p",
|
|
2901
|
+
"-t",
|
|
2902
|
+
target.id
|
|
2903
|
+
];
|
|
2904
|
+
if (lines === "all") args.push("-S", "-");
|
|
2905
|
+
else if (lines != null) args.push("-S", `-${lines}`);
|
|
2906
|
+
return exec("tmux", args) ?? "";
|
|
2907
|
+
}
|
|
2908
|
+
function toTmuxKey(key) {
|
|
2909
|
+
return TMUX_KEY_RENAMES[key] ?? key;
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* `tmux list-panes -a -F '#{pane_id} #{session_name} #{window_id}'` lists every pane server-wide.
|
|
2913
|
+
* Resolving fails — no line's pane id matches `id` — when the pane no longer exists in the backend,
|
|
2914
|
+
* and that must throw so `focus` never issues a switch-client/select-window against a pane it
|
|
2915
|
+
* couldn't actually resolve.
|
|
2916
|
+
*/
|
|
2917
|
+
function parsePaneLocation(out, id) {
|
|
2918
|
+
const line = (out ?? "").split("\n").find((l) => l.split(" ")[0] === id);
|
|
2919
|
+
if (!line) throw new Error(`peer's pane ${id} could not be resolved to beam to`);
|
|
2920
|
+
const [, sessionName, windowId] = line.split(" ");
|
|
2921
|
+
return {
|
|
2922
|
+
sessionName,
|
|
2923
|
+
windowId
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
//#endregion
|
|
2927
|
+
//#region src/new-id.ts
|
|
2928
|
+
/** The real id source — a v4 UUID. The shell binds this; core takes `NewId` as a parameter. */
|
|
2929
|
+
const nodeNewId = () => randomUUID();
|
|
2930
|
+
//#endregion
|
|
2931
|
+
//#region src/mux.wezterm.ts
|
|
2932
|
+
/**
|
|
2933
|
+
* WezTerm backend — detected via `$WEZTERM_PANE`. Drives WezTerm's built-in multiplexer through
|
|
2934
|
+
* `wezterm cli …` (https://wezterm.org/cli/general.html), the same synchronous-CLI shape tmux and
|
|
2935
|
+
* herdr already give `Exec`.
|
|
2936
|
+
*
|
|
2937
|
+
* Probed from `wezterm cli --help`/the CLI reference docs only — there is no live WezTerm GUI in
|
|
2938
|
+
* this sandbox, so nothing here carries the "verified against a live binary" claim `session.tmux.ts`
|
|
2939
|
+
* and `session.herdr.ts` make. Several real capability gaps fell out of that probe, not just missing
|
|
2940
|
+
* polish:
|
|
2941
|
+
*
|
|
2942
|
+
* - **No `--env` on `spawn`/`split-pane` at all.** Unlike herdr (native everywhere except one
|
|
2943
|
+
* worktree route), WezTerm's CLI has no env flag on ANY space-creating command — every route is
|
|
2944
|
+
* the exception, so `open`'s env always rides the `envFallback` compensation (a `env K=V` prefix
|
|
2945
|
+
* on the launch command, or a stderr warning with none to ride), never a native flag.
|
|
2946
|
+
* - **No way to title a PANE**, at birth or after — `set-tab-title`/`set-window-title` exist, there
|
|
2947
|
+
* is no pane equivalent. `rename(..., 'pane', …)` throws; `open`'s pane-tier `label` degrades to a
|
|
2948
|
+
* stderr warning rather than silently dropping it or failing the whole open.
|
|
2949
|
+
* - **No focus-query primitive** — `list --format json`'s documented fields carry no active/focused
|
|
2950
|
+
* indicator for a pane, tab, or window. `isPaneFocused` always answers `undefined`, which is the
|
|
2951
|
+
* seam's own honest answer for "no primitive to report focus", not a workaround.
|
|
2952
|
+
* - **No per-key press primitive** — there is no `send-keys`-shaped verb, only `send-text`. The core
|
|
2953
|
+
* vocabulary is instead realized by encoding each key as its raw terminal byte sequence and typing
|
|
2954
|
+
* it via `send-text --no-paste`; see `WEZTERM_KEY_BYTES`.
|
|
2955
|
+
* - **No pane geometry** — `list --format json` reports a pane's `size` (rows/cols) but no position,
|
|
2956
|
+
* so there is nothing to build a `PaneRect` from. `describeRegion`/`describeWorkspace` are omitted
|
|
2957
|
+
* entirely, the same optional-omission `template save` already handles for a backend that cannot.
|
|
2958
|
+
* - **No git-worktree concept in the CLI at all** — no `worktree` subcommand, so like tmux this
|
|
2959
|
+
* backend never binds one to a workspace; `worktree` is omitted.
|
|
2960
|
+
*
|
|
2961
|
+
* `spawn`/`split-pane` report only the new pane's bare id — unlike tmux/herdr, which embed the tab
|
|
2962
|
+
* (and workspace) in the same `-F`/JSON envelope the pane id rides out on. So `OpenedPane.tab` costs
|
|
2963
|
+
* a follow-up `wezterm cli list --format json` lookup here, not a free read of output already held.
|
|
2964
|
+
*/
|
|
2965
|
+
/**
|
|
2966
|
+
* Build the WezTerm adapter over its injected effects. The only effect it takes is `newId` — the id
|
|
2967
|
+
* source for the fresh workspace name minted when the caller opens a `workspace` without a `label`.
|
|
2968
|
+
* Exported as a factory (with `weztermMuxAdapter` binding the real one below) so a test can inject a
|
|
2969
|
+
* deterministic id instead of a UUID, exactly the seam `Exec`/`TemplateStore` already are.
|
|
2970
|
+
*/
|
|
2971
|
+
function createWeztermAdapter(deps) {
|
|
2972
|
+
const adapter = {
|
|
2973
|
+
name: "wezterm",
|
|
2974
|
+
canSizeSplits: true,
|
|
2975
|
+
/**
|
|
2976
|
+
* `false`: WezTerm's CLI documents no suppress-focus flag on any creating verb — neither
|
|
2977
|
+
* `cli spawn` (which opens a tab or a window) nor `cli split-pane` — and both activate what they
|
|
2978
|
+
* create. So every open moves the user, and this says so rather than pretending otherwise.
|
|
2979
|
+
*
|
|
2980
|
+
* There is nothing to emulate it with. Re-activating the caller's pane afterward would be a
|
|
2981
|
+
* `cli activate-pane` — a second visible focus move, landing the user back where they started
|
|
2982
|
+
* after a visible detour — which is worse than the honest `false`, not better.
|
|
2983
|
+
*
|
|
2984
|
+
* Read off the WezTerm CLI reference, NOT verified against a live binary: there is no wezterm on
|
|
2985
|
+
* the machine this was written on, the same disclaimer the rest of this header carries.
|
|
2986
|
+
*/
|
|
2987
|
+
opensWithoutStealingFocus: false,
|
|
2988
|
+
open(exec, opts) {
|
|
2989
|
+
const at = opts.at ?? "tab";
|
|
2990
|
+
if (at === "workspace") {
|
|
2991
|
+
const workspace = opts.label ?? `cyber-mux-${deps.newId().slice(0, 8)}`;
|
|
2992
|
+
const out = exec("wezterm", [
|
|
2993
|
+
"cli",
|
|
2994
|
+
"spawn",
|
|
2995
|
+
"--new-window",
|
|
2996
|
+
"--workspace",
|
|
2997
|
+
workspace,
|
|
2998
|
+
"--cwd",
|
|
2999
|
+
opts.cwd
|
|
3000
|
+
]);
|
|
3001
|
+
if (!out) throw new Error(withReason(exec, "wezterm cli spawn --new-window failed"));
|
|
3002
|
+
const pane = out.trim();
|
|
3003
|
+
if (!pane) throw new Error("wezterm cli spawn --new-window did not report the new pane id");
|
|
3004
|
+
const opened = {
|
|
3005
|
+
id: pane,
|
|
3006
|
+
tab: resolveTab(exec, pane),
|
|
3007
|
+
workspace
|
|
3008
|
+
};
|
|
3009
|
+
runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
|
|
3010
|
+
return opened;
|
|
3011
|
+
}
|
|
3012
|
+
if (at === "tab") {
|
|
3013
|
+
const out = exec("wezterm", [
|
|
3014
|
+
"cli",
|
|
3015
|
+
"spawn",
|
|
3016
|
+
...opts.within ? ["--window-id", resolveWorkspaceWindow(exec, opts.within)] : [],
|
|
3017
|
+
"--cwd",
|
|
3018
|
+
opts.cwd
|
|
3019
|
+
]);
|
|
3020
|
+
if (!out) throw new Error(withReason(exec, "wezterm cli spawn failed"));
|
|
3021
|
+
const pane = out.trim();
|
|
3022
|
+
if (!pane) throw new Error("wezterm cli spawn did not report the new pane id");
|
|
3023
|
+
const opened = withTabAndWorkspace(exec, pane);
|
|
3024
|
+
if (opts.label) adapter.rename(exec, { id: opened.tab }, "tab", opts.label);
|
|
3025
|
+
runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
|
|
3026
|
+
return opened;
|
|
3027
|
+
}
|
|
3028
|
+
if (at === "pane:float") refuseFloatingPane(adapter.name);
|
|
3029
|
+
const direction = at === "pane:down" ? ["--bottom"] : ["--right"];
|
|
3030
|
+
const from = opts.from ? ["--pane-id", opts.from.id] : [];
|
|
3031
|
+
const size = opts.ratio != null ? ["--percent", toWeztermSize(opts.ratio)] : [];
|
|
3032
|
+
const out = exec("wezterm", [
|
|
3033
|
+
"cli",
|
|
3034
|
+
"split-pane",
|
|
3035
|
+
...direction,
|
|
3036
|
+
...from,
|
|
3037
|
+
...size,
|
|
3038
|
+
"--cwd",
|
|
3039
|
+
opts.cwd
|
|
3040
|
+
]);
|
|
3041
|
+
if (!out) throw new Error(withReason(exec, "wezterm cli split-pane failed"));
|
|
3042
|
+
const pane = out.trim();
|
|
3043
|
+
if (!pane) throw new Error("wezterm cli split-pane did not report the new pane id");
|
|
3044
|
+
const opened = withTabAndWorkspace(exec, pane);
|
|
3045
|
+
if (opts.label) process.stderr.write(`wezterm cannot name a pane — "${opts.label}" was not set on pane ${opened.id}\n`);
|
|
3046
|
+
runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
|
|
3047
|
+
return opened;
|
|
3048
|
+
},
|
|
3049
|
+
rename(exec, target, tier, name) {
|
|
3050
|
+
if (tier === "tab") {
|
|
3051
|
+
exec("wezterm", [
|
|
3052
|
+
"cli",
|
|
3053
|
+
"set-tab-title",
|
|
3054
|
+
"--tab-id",
|
|
3055
|
+
target.id,
|
|
3056
|
+
name
|
|
3057
|
+
]);
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
throw new Error(`wezterm cannot name a pane (only a tab or window) — asked to rename ${target.id}`);
|
|
3061
|
+
},
|
|
3062
|
+
group() {},
|
|
3063
|
+
sendText(exec, target, text) {
|
|
3064
|
+
exec("wezterm", [
|
|
3065
|
+
"cli",
|
|
3066
|
+
"send-text",
|
|
3067
|
+
"--pane-id",
|
|
3068
|
+
target.id,
|
|
3069
|
+
text
|
|
3070
|
+
]);
|
|
3071
|
+
},
|
|
3072
|
+
sendKeys(exec, target, keys) {
|
|
3073
|
+
const bytes = keys.map((k) => WEZTERM_KEY_BYTES[k] ?? k).join("");
|
|
3074
|
+
exec("wezterm", [
|
|
3075
|
+
"cli",
|
|
3076
|
+
"send-text",
|
|
3077
|
+
"--pane-id",
|
|
3078
|
+
target.id,
|
|
3079
|
+
"--no-paste",
|
|
3080
|
+
bytes
|
|
3081
|
+
]);
|
|
3082
|
+
},
|
|
3083
|
+
submit(exec, target, text) {
|
|
3084
|
+
if (!text) {
|
|
3085
|
+
adapter.sendKeys(exec, target, ["Enter"]);
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
adapter.sendText(exec, target, text);
|
|
3089
|
+
adapter.sendKeys(exec, target, ["Enter"]);
|
|
3090
|
+
},
|
|
3091
|
+
read(exec, target, opts) {
|
|
3092
|
+
const text = getText(exec, target, opts?.lines);
|
|
3093
|
+
if (!opts?.truncation) return { text };
|
|
3094
|
+
if (opts.lines === "all") return {
|
|
3095
|
+
text,
|
|
3096
|
+
truncated: false
|
|
3097
|
+
};
|
|
3098
|
+
return {
|
|
3099
|
+
text,
|
|
3100
|
+
truncated: isReadTruncated(text, getText(exec, target, (opts.lines ?? 0) + 1))
|
|
3101
|
+
};
|
|
3102
|
+
},
|
|
3103
|
+
waitForOutput(exec, target, opts) {
|
|
3104
|
+
return pollForOutput(adapter, exec, target, opts);
|
|
3105
|
+
},
|
|
3106
|
+
focus(exec, target) {
|
|
3107
|
+
exec("wezterm", [
|
|
3108
|
+
"cli",
|
|
3109
|
+
"activate-pane",
|
|
3110
|
+
"--pane-id",
|
|
3111
|
+
target.id
|
|
3112
|
+
]);
|
|
3113
|
+
},
|
|
3114
|
+
teardown(exec, target) {
|
|
3115
|
+
exec("wezterm", [
|
|
3116
|
+
"cli",
|
|
3117
|
+
"kill-pane",
|
|
3118
|
+
"--pane-id",
|
|
3119
|
+
target.id
|
|
3120
|
+
]);
|
|
3121
|
+
},
|
|
3122
|
+
paneExists(exec, target) {
|
|
3123
|
+
return listWeztermPanes(exec).some((p) => String(p.pane_id) === target.id);
|
|
3124
|
+
},
|
|
3125
|
+
isPaneFocused() {},
|
|
3126
|
+
listPanes(exec) {
|
|
3127
|
+
return listWeztermPanes(exec).map((p) => {
|
|
3128
|
+
const pane = {
|
|
3129
|
+
id: String(p.pane_id),
|
|
3130
|
+
mux: "wezterm",
|
|
3131
|
+
floating: false
|
|
3132
|
+
};
|
|
3133
|
+
const cwd = weztermCwd(p.cwd);
|
|
3134
|
+
if (cwd) pane.cwd = cwd;
|
|
3135
|
+
return pane;
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
};
|
|
3139
|
+
return adapter;
|
|
3140
|
+
}
|
|
3141
|
+
const weztermMuxAdapter = createWeztermAdapter({ newId: nodeNewId });
|
|
3142
|
+
/**
|
|
3143
|
+
* `wezterm cli spawn`/`split-pane` report ONLY the new pane's bare id on stdout — unlike tmux/herdr,
|
|
3144
|
+
* neither embeds the tab (or workspace) in that same output. So the tab this pane landed in is a
|
|
3145
|
+
* follow-up `list --format json` lookup rather than a free read of an envelope already held.
|
|
3146
|
+
* `OpenedPane.tab` is still required — every multiplexer has the Tab level — it simply costs more
|
|
3147
|
+
* here than the "no extra call" property tmux/herdr get to claim.
|
|
3148
|
+
*/
|
|
3149
|
+
function resolveTab(exec, pane) {
|
|
3150
|
+
const found = listWeztermPanes(exec).find((p) => String(p.pane_id) === pane);
|
|
3151
|
+
if (!found) throw new Error(`wezterm did not report a tab for the new pane ${pane}`);
|
|
3152
|
+
return String(found.tab_id);
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* A window of the named workspace — what `spawn --window-id` needs to place a tab in a workspace
|
|
3156
|
+
* WezTerm's CLI cannot target directly (`--workspace` only names the space a `--new-window` spawn
|
|
3157
|
+
* creates, so it cannot place a tab in an existing one).
|
|
3158
|
+
*
|
|
3159
|
+
* Throws rather than falling back to an untargeted spawn: an unresolvable anchor means the workspace
|
|
3160
|
+
* is gone, and the untargeted spawn is exactly the wrong-space bug `within` was added to close.
|
|
3161
|
+
*/
|
|
3162
|
+
function resolveWorkspaceWindow(exec, workspace) {
|
|
3163
|
+
const found = listWeztermPanes(exec).find((p) => p.workspace === workspace);
|
|
3164
|
+
if (!found) throw new Error(`wezterm reported no window in workspace ${workspace} to open a tab in`);
|
|
3165
|
+
return String(found.window_id);
|
|
3166
|
+
}
|
|
3167
|
+
/** `resolveTab` plus the workspace the same lookup already answers — one call serves both facts. */
|
|
3168
|
+
function withTabAndWorkspace(exec, pane) {
|
|
3169
|
+
const found = listWeztermPanes(exec).find((p) => String(p.pane_id) === pane);
|
|
3170
|
+
if (!found) throw new Error(`wezterm did not report a tab for the new pane ${pane}`);
|
|
3171
|
+
return {
|
|
3172
|
+
id: pane,
|
|
3173
|
+
tab: String(found.tab_id),
|
|
3174
|
+
workspace: found.workspace
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Env is native at NO tier on this backend — `spawn`/`split-pane` take no `--env` at all, unlike
|
|
3179
|
+
* herdr (native everywhere but one worktree route). So every `open` funnels through the same
|
|
3180
|
+
* fallback herdr's worktree route uses: with a launch command, env rides in as an `env K=V` prefix;
|
|
3181
|
+
* with none, a warning names what did not land. `envFallback` is a no-op when there is no env to
|
|
3182
|
+
* carry, so this is safe to call unconditionally.
|
|
3183
|
+
*/
|
|
3184
|
+
function runLaunch$1(adapter, exec, target, env, launch) {
|
|
3185
|
+
const fallback = envFallback(env, launch);
|
|
3186
|
+
if (fallback.kind === "dropped") {
|
|
3187
|
+
process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this wezterm pane — wezterm has no --env flag on any space-creating command
|
|
3188
|
+
`);
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
|
|
3192
|
+
}
|
|
3193
|
+
/** One `wezterm cli list --format json` call, parsed defensively — never throws on bad output. */
|
|
3194
|
+
function listWeztermPanes(exec) {
|
|
3195
|
+
const out = exec("wezterm", [
|
|
3196
|
+
"cli",
|
|
3197
|
+
"list",
|
|
3198
|
+
"--format",
|
|
3199
|
+
"json"
|
|
3200
|
+
]);
|
|
3201
|
+
if (!out) return [];
|
|
3202
|
+
let parsed;
|
|
3203
|
+
try {
|
|
3204
|
+
parsed = JSON.parse(out);
|
|
3205
|
+
} catch {
|
|
3206
|
+
return [];
|
|
3207
|
+
}
|
|
3208
|
+
if (!Array.isArray(parsed)) return [];
|
|
3209
|
+
return parsed.filter((p) => p != null && p.pane_id != null && p.tab_id != null && p.window_id != null);
|
|
3210
|
+
}
|
|
3211
|
+
/** `cwd` is reported as a `file://` URI; strip the scheme and host down to the bare path. */
|
|
3212
|
+
function weztermCwd(cwd) {
|
|
3213
|
+
if (!cwd) return void 0;
|
|
3214
|
+
const match = /^file:\/\/[^/]*(\/.*)$/.exec(cwd);
|
|
3215
|
+
return match ? decodeURIComponent(match[1]) : cwd;
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* `ratio` is the fraction kept by the ORIGINAL pane; `--percent` sizes the NEW one (the issue's own
|
|
3219
|
+
* probe note, #47) — the same inversion tmux's `-l` needs, unlike herdr's pass-through.
|
|
3220
|
+
*/
|
|
3221
|
+
function toWeztermSize(ratio) {
|
|
3222
|
+
assertRatioInRange(ratio);
|
|
3223
|
+
return String(Math.round((1 - ratio) * 100));
|
|
3224
|
+
}
|
|
3225
|
+
/**
|
|
3226
|
+
* The core vocabulary, realized as raw terminal bytes rather than a backend key NAME — there is no
|
|
3227
|
+
* send-keys-shaped verb to name a key TO, only `send-text`. Escape sequences are the ANSI/VT100
|
|
3228
|
+
* "cursor key mode" forms every common shell/program already parses; `Backspace` sends DEL (`\x7f`),
|
|
3229
|
+
* what most terminals emit for that key today (probed, not read off any wezterm spec — wezterm ships
|
|
3230
|
+
* no such table because it has no key-name CLI surface to spec).
|
|
3231
|
+
*
|
|
3232
|
+
* `Home`/`End`/`Delete`/`Insert`/`PageUp`/`PageDown` are extras beyond the core, included for the
|
|
3233
|
+
* same reason tmux "knows" `Home` even though the core vocabulary does not name it: these are
|
|
3234
|
+
* standard-enough ANSI keys that encoding them costs nothing extra and a caller reaching for one
|
|
3235
|
+
* should not silently get the literal word typed instead.
|
|
3236
|
+
*/
|
|
3237
|
+
const WEZTERM_KEY_BYTES = {
|
|
3238
|
+
Up: "\x1B[A",
|
|
3239
|
+
Down: "\x1B[B",
|
|
3240
|
+
Right: "\x1B[C",
|
|
3241
|
+
Left: "\x1B[D",
|
|
3242
|
+
Enter: "\r",
|
|
3243
|
+
Escape: "\x1B",
|
|
3244
|
+
Tab: " ",
|
|
3245
|
+
Space: " ",
|
|
3246
|
+
Backspace: "",
|
|
3247
|
+
"C-c": "",
|
|
3248
|
+
F1: "\x1BOP",
|
|
3249
|
+
F2: "\x1BOQ",
|
|
3250
|
+
F3: "\x1BOR",
|
|
3251
|
+
F4: "\x1BOS",
|
|
3252
|
+
F5: "\x1B[15~",
|
|
3253
|
+
F6: "\x1B[17~",
|
|
3254
|
+
F7: "\x1B[18~",
|
|
3255
|
+
F8: "\x1B[19~",
|
|
3256
|
+
F9: "\x1B[20~",
|
|
3257
|
+
F10: "\x1B[21~",
|
|
3258
|
+
F11: "\x1B[23~",
|
|
3259
|
+
F12: "\x1B[24~",
|
|
3260
|
+
Home: "\x1B[H",
|
|
3261
|
+
End: "\x1B[F",
|
|
3262
|
+
Delete: "\x1B[3~",
|
|
3263
|
+
Insert: "\x1B[2~",
|
|
3264
|
+
PageUp: "\x1B[5~",
|
|
3265
|
+
PageDown: "\x1B[6~"
|
|
3266
|
+
};
|
|
3267
|
+
/**
|
|
3268
|
+
* One spelling of the capture, taken by `read` for the snapshot AND for its one-row-deeper truncation
|
|
3269
|
+
* probe, so the two differ only in the number they are meant to disagree about.
|
|
3270
|
+
*
|
|
3271
|
+
* `--start-line` counts backward into scrollback from 0 (the top of the visible screen); the end
|
|
3272
|
+
* defaults to the bottom of the screen. Negative-N approximates "last N lines" the way tmux's `-S -N`
|
|
3273
|
+
* does, though the two are not guaranteed to line up cell-for-cell. `lines` omitted is WezTerm's own
|
|
3274
|
+
* default window — the visible screen, with no `--start-line` at all.
|
|
3275
|
+
*/
|
|
3276
|
+
function getText(exec, target, lines) {
|
|
3277
|
+
const args = [
|
|
3278
|
+
"cli",
|
|
3279
|
+
"get-text",
|
|
3280
|
+
"--pane-id",
|
|
3281
|
+
target.id
|
|
3282
|
+
];
|
|
3283
|
+
const start = lines === "all" ? FULL_SCROLLBACK_LINES : lines;
|
|
3284
|
+
if (start != null) args.push("--start-line", String(-start));
|
|
3285
|
+
return exec("wezterm", args) ?? "";
|
|
3286
|
+
}
|
|
3287
|
+
//#endregion
|
|
3288
|
+
//#region src/mux.zellij.ts
|
|
3289
|
+
/**
|
|
3290
|
+
* Zellij backend — detected via `$ZELLIJ`. Drives Zellij's built-in multiplexer through `zellij
|
|
3291
|
+
* action …` (https://zellij.dev/documentation/cli-actions), the same synchronous-CLI shape tmux,
|
|
3292
|
+
* herdr and wezterm already give `Exec`.
|
|
3293
|
+
*
|
|
3294
|
+
* Requires **Zellij ≥ 0.45.0**. The whole per-pane-addressable surface this adapter stands on landed
|
|
3295
|
+
* in 0.44.0 (2026-03-23): `--pane-id` across the action verbs, `list-panes --json`, ids returned from
|
|
3296
|
+
* `new-pane`/`new-tab`, and `focus-pane-id` (0.44.1). Before that release Zellij's CLI was almost
|
|
3297
|
+
* entirely FOCUS-relative — no stable per-pane handle — and no faithful adapter was possible. On an
|
|
3298
|
+
* older binary these commands fail and the adapter surfaces the failure rather than silently
|
|
3299
|
+
* mis-targeting the focused pane.
|
|
3300
|
+
*
|
|
3301
|
+
* The floor moved from 0.44.1 to **0.45.0** (2026-08-20) for `--no-focus`, which is what lets this
|
|
3302
|
+
* backend answer `opensWithoutStealingFocus` at all. Raising it rather than keeping the old
|
|
3303
|
+
* focus-stealing path as a fallback, for four reasons that compound:
|
|
3304
|
+
*
|
|
3305
|
+
* - The seam member is a **static declaration**, like `canSizeSplits` and `canFloatPanes` — there is
|
|
3306
|
+
* no runtime probe behind any of them. An adapter cannot declare `true` and then quietly degrade on
|
|
3307
|
+
* an old binary without lying in precisely the way the declaration exists to prevent, and a
|
|
3308
|
+
* conditional value has nowhere to live.
|
|
3309
|
+
* - Version-probing to decide would be a NEW behavior for this file, which reads no version anywhere,
|
|
3310
|
+
* and would cost an exec on every open to pre-empt a failure zellij already reports precisely.
|
|
3311
|
+
* - A fallback would be a second code path that nothing here can ever exercise: there is no zellij on
|
|
3312
|
+
* the machine this was written on, and the integration suite skips its rows without one (issue
|
|
3313
|
+
* #125). Untested code guarding an untestable condition is a liability, not a safety net.
|
|
3314
|
+
* - The 0.44 failure is LOUD and creates nothing. Zellij's CLI parses in clap's strict mode (the
|
|
3315
|
+
* v0.45.0 tree sets no `ignore_errors`, `allow_hyphen_values`, `allow_external_subcommands`, or
|
|
3316
|
+
* `trailing_var_arg` anywhere in `zellij-utils/src/cli.rs`), so an unknown `--no-focus` is a parse
|
|
3317
|
+
* error on stderr and a nonzero exit — surfaced by the `withReason` throw in `open`, which names the
|
|
3318
|
+
* command. There is no silent wrong-pane for it to become, which is the one failure mode worth
|
|
3319
|
+
* engineering against. tmux sets this precedent exactly: `new-pane` is declared unconditionally and
|
|
3320
|
+
* an older binary fails with tmux's own `unknown command`.
|
|
3321
|
+
*
|
|
3322
|
+
* **The 0.45.0 surface is UNVERIFIED against a running binary** — read out of the v0.45.0 source tree
|
|
3323
|
+
* (`zellij-utils/src/cli.rs`, `zellij-server/src/route.rs`), not driven. Everything below attributed
|
|
3324
|
+
* to 0.44.3 was driven, and stays driven; the two claims are kept apart on purpose.
|
|
3325
|
+
*
|
|
3326
|
+
* Originally probed from the Zellij docs + CHANGELOG alone; now **driven against a live 0.44.3
|
|
3327
|
+
* binary** by `mux.zellij.integration.test.ts`, so the command shapes below carry the same
|
|
3328
|
+
* verified-against-a-real-binary claim `session.tmux.ts`/`session.herdr.ts` make. What the live probe
|
|
3329
|
+
* changed, and what a doc probe could not have known:
|
|
3330
|
+
*
|
|
3331
|
+
* - The id forms are confirmed and asymmetric: `new-pane` prints the PREFIXED `terminal_N`, while
|
|
3332
|
+
* `list-panes --json` reports `id` as a BARE integer. `samePane` is what makes those the same pane,
|
|
3333
|
+
* so it is load-bearing rather than defensive.
|
|
3334
|
+
* - That bare integer is NOT unique: a live session reports `id: 0` for both its suppressed
|
|
3335
|
+
* `zellij:link` PLUGIN pane and its first terminal pane. The number is unique only within a kind,
|
|
3336
|
+
* and `is_plugin` is what says which kind — so `listZellijPanes` qualifies every bare id to
|
|
3337
|
+
* `terminal_N`/`plugin_N` before anything compares or reports it. Both prefixed forms are what
|
|
3338
|
+
* `--pane-id` itself accepts, and a bare `N` addresses the TERMINAL pane (verified: renaming
|
|
3339
|
+
* `--pane-id 3` renames terminal 3 and leaves plugin 3 alone), which is exactly the fold
|
|
3340
|
+
* `normalizePaneId` makes.
|
|
3341
|
+
* - Which `list-panes --json` field names are real, and what each one carries. The label guard reads
|
|
3342
|
+
* `terminal_command`, not `pane_command` — a doc probe had that one backwards; see `ZellijPane`.
|
|
3343
|
+
* `pane_cwd` and `pane_command` are real too, and both are present ONLY on a terminal pane: a
|
|
3344
|
+
* plugin pane's record omits the keys entirely, which is how a probe that sampled one concluded
|
|
3345
|
+
* the fields did not exist at all. That is the failure mode of a doc probe, and the reason this
|
|
3346
|
+
* file now has a suite.
|
|
3347
|
+
* - `new-pane --direction` requires an attached client focused on a TERMINAL pane, and fails
|
|
3348
|
+
* SILENTLY without one — it prints a plausible new pane id and exits 0 having created nothing.
|
|
3349
|
+
* Re-probed against 0.44.3 with the client parked on zellij's own release-notes PLUGIN pane, which
|
|
3350
|
+
* is where a fresh attach lands: the split printed `terminal_2`, exited 0, and the pane count did
|
|
3351
|
+
* not move. `open()` does not propagate that phantom — see `openedForPane`, which now believes a
|
|
3352
|
+
* reported id only where it names a pane that was NOT already standing.
|
|
3353
|
+
* - **A `zellij action` reply can be delivered to the WRONG command**, and **a session no client has
|
|
3354
|
+
* ever attached to reports `list-panes --json` as an empty ARRAY** rather than as an error. Both
|
|
3355
|
+
* are the reason this file retries a listing instead of reading one empty answer as the truth; see
|
|
3356
|
+
* `LIST_PANES_ATTEMPTS` for the repro behind the first and `mux.zellij.integration.test.ts`'s
|
|
3357
|
+
* readiness gate for the second. So the older claim that every verb but `--direction` works against
|
|
3358
|
+
* a client-less session is wrong: the listing is silent there, and everything in this file that
|
|
3359
|
+
* resolves an id reads the listing.
|
|
3360
|
+
*
|
|
3361
|
+
* Real capability shape that fell out of the probe:
|
|
3362
|
+
*
|
|
3363
|
+
* - **The session is the workspace tier, but a SEPARATE session's panes are un-addressable through
|
|
3364
|
+
* this seam.** Zellij's native tiers are Session › Tab › Pane, so a `workspace` placement would
|
|
3365
|
+
* naturally open a new session (`zellij attach --create-background`). But Zellij pane ids are
|
|
3366
|
+
* SESSION-SCOPED — driving another session's pane requires `zellij --session <name> action …` — and
|
|
3367
|
+
* `MuxTarget` carries only an opaque pane id, no session. A pane returned from a freshly-created
|
|
3368
|
+
* session would therefore fail on the next `write`/`read`/`focus`, which is a trap, not a feature.
|
|
3369
|
+
* So this adapter operates within the AMBIENT session: `workspace` collapses to a new **tab**, the
|
|
3370
|
+
* same collapse tmux makes for a Window. Unlike tmux, the occupied workspace IS reported —
|
|
3371
|
+
* `OpenedPane.workspace` carries the ambient session name (`deps.session`), because every pane
|
|
3372
|
+
* genuinely lives in that session. (Lifting the collapse needs a seam change: a session qualifier
|
|
3373
|
+
* on `MuxTarget`. Recorded in the ADR log.)
|
|
3374
|
+
* - **No `--env` on `new-pane`/`new-tab`.** Like wezterm, env is native at no tier, so every open
|
|
3375
|
+
* rides the `envFallback` compensation (an `env K=V` prefix on the launch command, or a stderr
|
|
3376
|
+
* warning when there is no command to ride).
|
|
3377
|
+
* - **Tiled splits cannot be sized.** `new-pane`'s `-x/-y/--width/--height` all require `--floating`;
|
|
3378
|
+
* a tiled `pane:*` split is always even. So `canSizeSplits` is omitted and a `ratio` is dropped —
|
|
3379
|
+
* callers degrade to the even default, the same path the flag's absence already documents.
|
|
3380
|
+
* - **`new-pane` has no split-TARGET flag.** It splits the focused pane (or the biggest space); the
|
|
3381
|
+
* only flag is `--tab-id`. So `from` — which pane a `pane:*` split lands beside — is honored by
|
|
3382
|
+
* FOCUSING that pane first, the sole way to choose the split target. That is still a real focus
|
|
3383
|
+
* move; what changed in 0.45.0 is that it is now UNDONE, not that it stopped happening. See
|
|
3384
|
+
* `opensWithoutStealingFocus`, and `open` for why `--no-focus` cannot be the answer on that path —
|
|
3385
|
+
* it re-anchors the split on the ISSUING pane and would make the focus move pointless and the target
|
|
3386
|
+
* wrong.
|
|
3387
|
+
* - **No pane geometry adapter.** `list-panes --json` does report `pane_x`/`pane_y`, so `regions`
|
|
3388
|
+
* (`describeRegion`/`describeWorkspace`) is IMPLEMENTABLE here — unlike wezterm, which lacks
|
|
3389
|
+
* position entirely — but the cell-vs-divider semantics of Zellij's rects need a live binary to pin,
|
|
3390
|
+
* so it is deliberately left as a follow-up rather than guessed. `template save` refuses on zellij
|
|
3391
|
+
* by naming the backend, the same optional-absence it already handles for wezterm.
|
|
3392
|
+
* - **No git-worktree concept in the CLI.** No `worktree` subcommand, so — like tmux and wezterm —
|
|
3393
|
+
* this backend never binds one to a workspace; callers fall back to plain git plus `open()`.
|
|
3394
|
+
*
|
|
3395
|
+
* Wins Zellij has that wezterm does not: it CAN name a pane (`new-pane --name` / `rename-pane`), and
|
|
3396
|
+
* it CAN report which pane is focused (`list-panes --json`'s `is_focused`), so `isPaneFocused`
|
|
3397
|
+
* answers a real value rather than always `unknown`.
|
|
3398
|
+
*/
|
|
3399
|
+
/**
|
|
3400
|
+
* Build the Zellij adapter over its injected effects. The only effect it takes is `session` — the
|
|
3401
|
+
* ambient Zellij session name (`$ZELLIJ_SESSION_NAME`), reported as `OpenedPane.workspace` so a
|
|
3402
|
+
* caller learns which workspace a new pane landed in. Undefined when unknown (the exported singleton),
|
|
3403
|
+
* in which case `workspace` is simply omitted — the same absent-not-false convention the seam uses
|
|
3404
|
+
* elsewhere. Exported as a factory (with `zellijMuxAdapter` binding the effect-less one) so
|
|
3405
|
+
* `backend.ts` can bind the real session name off `env` at resolution, exactly the seam `Exec` and
|
|
3406
|
+
* wezterm's `newId` already are.
|
|
3407
|
+
*/
|
|
3408
|
+
function createZellijAdapter(deps) {
|
|
3409
|
+
const adapter = {
|
|
3410
|
+
name: "zellij",
|
|
3411
|
+
canFloatPanes: true,
|
|
3412
|
+
/**
|
|
3413
|
+
* Every route this adapter can take ends with the caller's focus where it started, which is the
|
|
3414
|
+
* end-state the seam declares — NOT "focus never moves at any instant", which zellij cannot offer
|
|
3415
|
+
* on the one route that has to choose a split target. Two different mechanisms get there, and
|
|
3416
|
+
* which one applies is decided by whether `from` was named:
|
|
3417
|
+
*
|
|
3418
|
+
* - **No target to choose** (`new-tab`, and `new-pane` with no `from`): `--no-focus`, and nothing
|
|
3419
|
+
* moves at all. Zellij 0.45.0's flag, on the verbs this adapter uses.
|
|
3420
|
+
* - **A named `from`**: focus it, split it, then focus back to the pane that was focused before —
|
|
3421
|
+
* a real, visible round trip. `--no-focus` is deliberately NOT passed here; see `open`, where
|
|
3422
|
+
* passing it would silently split the WRONG pane.
|
|
3423
|
+
*
|
|
3424
|
+
* **Written blind, now driven in CI.** There is no zellij on the machine this was written on, so
|
|
3425
|
+
* `--no-focus` was read out of the v0.45.0 source tree (`zellij-utils/src/cli.rs`, the
|
|
3426
|
+
* `no_focus: bool` field on `CliAction::NewPane`/`NewTab`) rather than off a live `--help`. CI's
|
|
3427
|
+
* live-backends job pins 0.45.0 and runs `mux.zellij.integration.test.ts` against it, where two
|
|
3428
|
+
* rows assert the CLIENT does not move — one per mechanism above. That is the evidence for this
|
|
3429
|
+
* boolean. A LOCAL run is not: the suite skips every row when no binary is present and still
|
|
3430
|
+
* reports green (issue #125).
|
|
3431
|
+
*/
|
|
3432
|
+
opensWithoutStealingFocus: true,
|
|
3433
|
+
open(exec, opts) {
|
|
3434
|
+
const at = opts.at ?? "tab";
|
|
3435
|
+
if (at === "tab" || at === "workspace") {
|
|
3436
|
+
const args = [
|
|
3437
|
+
"action",
|
|
3438
|
+
"new-tab",
|
|
3439
|
+
"--no-focus",
|
|
3440
|
+
"--cwd",
|
|
3441
|
+
opts.cwd
|
|
3442
|
+
];
|
|
3443
|
+
if (opts.label) args.push("--name", opts.label);
|
|
3444
|
+
const before = paneIdSet(exec);
|
|
3445
|
+
const out = exec("zellij", args);
|
|
3446
|
+
if (out === null) throw new Error(withReason(exec, "zellij action new-tab failed"));
|
|
3447
|
+
const opened = openedForTab(exec, out.trim(), before, deps.session);
|
|
3448
|
+
runLaunch(adapter, exec, opened, opts.env, opts.launch);
|
|
3449
|
+
return opened;
|
|
3450
|
+
}
|
|
3451
|
+
const before = paneIdSet(exec);
|
|
3452
|
+
const restoreTo = opts.from ? clientPane(exec) : void 0;
|
|
3453
|
+
if (opts.from) adapter.focus(exec, opts.from);
|
|
3454
|
+
const placement = at === "pane:float" ? ["--floating"] : ["--direction", at === "pane:down" ? "down" : "right"];
|
|
3455
|
+
const focusFlag = opts.from ? [] : ["--no-focus"];
|
|
3456
|
+
const args = [
|
|
3457
|
+
"action",
|
|
3458
|
+
"new-pane",
|
|
3459
|
+
...placement,
|
|
3460
|
+
...focusFlag,
|
|
3461
|
+
"--cwd",
|
|
3462
|
+
opts.cwd
|
|
3463
|
+
];
|
|
3464
|
+
if (opts.label) args.push("--name", opts.label);
|
|
3465
|
+
const out = exec("zellij", args);
|
|
3466
|
+
if (out === null) throw new Error(withReason(exec, "zellij action new-pane failed"));
|
|
3467
|
+
const opened = openedForPane(exec, out.trim(), before, deps.session);
|
|
3468
|
+
if (restoreTo) exec("zellij", [
|
|
3469
|
+
"action",
|
|
3470
|
+
"focus-pane-id",
|
|
3471
|
+
restoreTo
|
|
3472
|
+
]);
|
|
3473
|
+
runLaunch(adapter, exec, opened, opts.env, opts.launch);
|
|
3474
|
+
return opened;
|
|
3475
|
+
},
|
|
3476
|
+
rename(exec, target, tier, name) {
|
|
3477
|
+
if (tier === "tab") {
|
|
3478
|
+
exec("zellij", [
|
|
3479
|
+
"action",
|
|
3480
|
+
"rename-tab-by-id",
|
|
3481
|
+
target.id,
|
|
3482
|
+
name
|
|
3483
|
+
]);
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
exec("zellij", [
|
|
3487
|
+
"action",
|
|
3488
|
+
"rename-pane",
|
|
3489
|
+
"--pane-id",
|
|
3490
|
+
target.id,
|
|
3491
|
+
name
|
|
3492
|
+
]);
|
|
3493
|
+
},
|
|
3494
|
+
group() {},
|
|
3495
|
+
sendText(exec, target, text) {
|
|
3496
|
+
exec("zellij", [
|
|
3497
|
+
"action",
|
|
3498
|
+
"write-chars",
|
|
3499
|
+
"--pane-id",
|
|
3500
|
+
target.id,
|
|
3501
|
+
text
|
|
3502
|
+
]);
|
|
3503
|
+
},
|
|
3504
|
+
sendKeys(exec, target, keys) {
|
|
3505
|
+
exec("zellij", [
|
|
3506
|
+
"action",
|
|
3507
|
+
"send-keys",
|
|
3508
|
+
"--pane-id",
|
|
3509
|
+
target.id,
|
|
3510
|
+
...keys.map(toZellijKey)
|
|
3511
|
+
]);
|
|
3512
|
+
},
|
|
3513
|
+
submit(exec, target, text) {
|
|
3514
|
+
if (!text) {
|
|
3515
|
+
adapter.sendKeys(exec, target, ["Enter"]);
|
|
3516
|
+
return;
|
|
3517
|
+
}
|
|
3518
|
+
adapter.sendText(exec, target, text);
|
|
3519
|
+
adapter.sendKeys(exec, target, ["Enter"]);
|
|
3520
|
+
},
|
|
3521
|
+
read(exec, target, opts) {
|
|
3522
|
+
if (opts?.lines === "all") {
|
|
3523
|
+
const text = exec("zellij", [
|
|
3524
|
+
"action",
|
|
3525
|
+
"dump-screen",
|
|
3526
|
+
"--pane-id",
|
|
3527
|
+
target.id,
|
|
3528
|
+
"--full"
|
|
3529
|
+
]) ?? "";
|
|
3530
|
+
return opts.truncation ? {
|
|
3531
|
+
text,
|
|
3532
|
+
truncated: false
|
|
3533
|
+
} : { text };
|
|
3534
|
+
}
|
|
3535
|
+
if (opts?.lines != null) {
|
|
3536
|
+
const full = exec("zellij", [
|
|
3537
|
+
"action",
|
|
3538
|
+
"dump-screen",
|
|
3539
|
+
"--pane-id",
|
|
3540
|
+
target.id,
|
|
3541
|
+
"--full"
|
|
3542
|
+
]) ?? "";
|
|
3543
|
+
const text = lastLines(full, opts.lines);
|
|
3544
|
+
return opts.truncation ? {
|
|
3545
|
+
text,
|
|
3546
|
+
truncated: isReadTruncated(text, full)
|
|
3547
|
+
} : { text };
|
|
3548
|
+
}
|
|
3549
|
+
const text = exec("zellij", [
|
|
3550
|
+
"action",
|
|
3551
|
+
"dump-screen",
|
|
3552
|
+
"--pane-id",
|
|
3553
|
+
target.id
|
|
3554
|
+
]) ?? "";
|
|
3555
|
+
if (!opts?.truncation) return { text };
|
|
3556
|
+
return {
|
|
3557
|
+
text,
|
|
3558
|
+
truncated: isReadTruncated(text, exec("zellij", [
|
|
3559
|
+
"action",
|
|
3560
|
+
"dump-screen",
|
|
3561
|
+
"--pane-id",
|
|
3562
|
+
target.id,
|
|
3563
|
+
"--full"
|
|
3564
|
+
]) ?? "")
|
|
3565
|
+
};
|
|
3566
|
+
},
|
|
3567
|
+
waitForOutput(exec, target, opts) {
|
|
3568
|
+
return pollForOutput(adapter, exec, target, opts);
|
|
3569
|
+
},
|
|
3570
|
+
focus(exec, target) {
|
|
3571
|
+
exec("zellij", [
|
|
3572
|
+
"action",
|
|
3573
|
+
"focus-pane-id",
|
|
3574
|
+
target.id
|
|
3575
|
+
]);
|
|
3576
|
+
},
|
|
3577
|
+
teardown(exec, target) {
|
|
3578
|
+
exec("zellij", [
|
|
3579
|
+
"action",
|
|
3580
|
+
"close-pane",
|
|
3581
|
+
"--pane-id",
|
|
3582
|
+
target.id
|
|
3583
|
+
]);
|
|
3584
|
+
},
|
|
3585
|
+
paneExists(exec, target) {
|
|
3586
|
+
return listZellijPanes(exec).some((p) => samePane(p.id, target.id));
|
|
3587
|
+
},
|
|
3588
|
+
isPaneFocused(exec, target) {
|
|
3589
|
+
const found = listZellijPanes(exec).find((p) => samePane(p.id, target.id));
|
|
3590
|
+
if (!found) return void 0;
|
|
3591
|
+
return found.is_focused === true;
|
|
3592
|
+
},
|
|
3593
|
+
listPanes(exec) {
|
|
3594
|
+
return listZellijPanes(exec).map((p) => {
|
|
3595
|
+
const pane = {
|
|
3596
|
+
id: p.id,
|
|
3597
|
+
mux: "zellij",
|
|
3598
|
+
floating: p.is_floating === true
|
|
3599
|
+
};
|
|
3600
|
+
if (p.pane_cwd) pane.cwd = p.pane_cwd;
|
|
3601
|
+
const label = zellijLabel(p.title, p.terminal_command);
|
|
3602
|
+
if (label) pane.label = label;
|
|
3603
|
+
return pane;
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
};
|
|
3607
|
+
return adapter;
|
|
3608
|
+
}
|
|
3609
|
+
const zellijMuxAdapter = createZellijAdapter({});
|
|
3610
|
+
/**
|
|
3611
|
+
* How many times a listing is re-asked before it is reported as empty, and how many times an open
|
|
3612
|
+
* re-looks for the pane it just made.
|
|
3613
|
+
*
|
|
3614
|
+
* Both exist for ONE verified defect in zellij 0.44.3's CLI, which no amount of adapter care can
|
|
3615
|
+
* prevent and every verb here rides on: **a `zellij action` reply can be delivered to the wrong
|
|
3616
|
+
* command.** Driven under CPU contention, a command exits 0 having printed NOTHING, and the reply it
|
|
3617
|
+
* should have received arrives on the stdout of the command issued after it. Reproduced by
|
|
3618
|
+
* alternating `new-tab` and `list-panes --json` 40 times on a loaded 2-core box — twice in 40, the
|
|
3619
|
+
* `new-tab` printed an empty string and the `list-panes` that followed it printed `27`. Two hundred
|
|
3620
|
+
* back-to-back `list-panes --json` calls with no mutating verb between them lost nothing, so it is
|
|
3621
|
+
* the mutating verbs that open the window.
|
|
3622
|
+
*
|
|
3623
|
+
* The consequences land squarely here. An empty reply to `list-panes --json` is not an empty session
|
|
3624
|
+
* — a live zellij session always has at least one pane — so reading it as one made every id
|
|
3625
|
+
* resolution in this file fail at once. And an id printed by `new-pane`/`new-tab` may belong to the
|
|
3626
|
+
* PREVIOUS command, so it can name a pane that has been standing all along.
|
|
3627
|
+
*
|
|
3628
|
+
* A retry is the honest remedy because the loss is per-call and independent: the same read reissued
|
|
3629
|
+
* answers. These are ceilings on a wedged server, not a wait that decides a pass.
|
|
3630
|
+
*
|
|
3631
|
+
* What a retry CANNOT reach, stated so a caller knows the edge: a misdelivered reply may also be a
|
|
3632
|
+
* valid pane array — an older one, which simply does not carry a pane opened since. Nothing in the
|
|
3633
|
+
* shape of that answer marks it stale, so `listPanes` cannot reject it and a single negative read
|
|
3634
|
+
* (`paneExists`, `isPaneFocused`) can be wrong. A caller that needs certainty on this backend re-asks;
|
|
3635
|
+
* `mux.zellij.integration.test.ts` does exactly that.
|
|
3636
|
+
*/
|
|
3637
|
+
const LIST_PANES_ATTEMPTS = 3;
|
|
3638
|
+
const OPEN_RESOLVE_ATTEMPTS = 10;
|
|
3639
|
+
/**
|
|
3640
|
+
* One `zellij action list-panes --json` read, parsed defensively — `undefined` for output that is not
|
|
3641
|
+
* a pane array, so a caller can tell "zellij did not answer" from "zellij answered, with no panes".
|
|
3642
|
+
* Every id is QUALIFIED on the way out (see `zellijPaneId`), so what this returns — and therefore
|
|
3643
|
+
* what `LivePane.id` carries and what `samePane` compares — names exactly one live pane.
|
|
3644
|
+
*/
|
|
3645
|
+
function readZellijPanes(exec) {
|
|
3646
|
+
const out = exec("zellij", [
|
|
3647
|
+
"action",
|
|
3648
|
+
"list-panes",
|
|
3649
|
+
"--json"
|
|
3650
|
+
]);
|
|
3651
|
+
if (!out) return void 0;
|
|
3652
|
+
let parsed;
|
|
3653
|
+
try {
|
|
3654
|
+
parsed = JSON.parse(out);
|
|
3655
|
+
} catch {
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
3659
|
+
return parsed.filter((p) => p != null && p.id != null).map((p) => ({
|
|
3660
|
+
...p,
|
|
3661
|
+
id: zellijPaneId(p.id, p.is_plugin)
|
|
3662
|
+
}));
|
|
3663
|
+
}
|
|
3664
|
+
/**
|
|
3665
|
+
* The id a listing record is reported under — qualified by KIND, because zellij's bare number is not
|
|
3666
|
+
* unique. Plugin panes and terminal panes are numbered in separate spaces, so a live session reports
|
|
3667
|
+
* `id: 0` for both its suppressed `zellij:link` plugin pane and its first terminal pane; reporting
|
|
3668
|
+
* both as `'0'` collapsed two genuinely different panes onto one `LivePane.id`, and every resolution
|
|
3669
|
+
* by id — `paneExists`, `isPaneFocused`, and `openedForPane`'s guard against a phantom `new-pane`
|
|
3670
|
+
* result — could then land on the wrong one.
|
|
3671
|
+
*
|
|
3672
|
+
* A bare integer therefore takes the `terminal_`/`plugin_` prefix its `is_plugin` names, which is the
|
|
3673
|
+
* form `new-pane` already prints and the form `--pane-id` accepts for BOTH kinds (verified against a
|
|
3674
|
+
* live 0.44.3). An id that already carries a prefix is left exactly as it is — this qualifies what
|
|
3675
|
+
* zellij left ambiguous, it does not rewrite what zellij spelled out.
|
|
3676
|
+
*
|
|
3677
|
+
* A record with no `is_plugin` at all is read as a terminal pane: that is the same answer
|
|
3678
|
+
* `normalizePaneId` gives a bare id, and the kind zellij's own bare-id addressing resolves to.
|
|
3679
|
+
*/
|
|
3680
|
+
function zellijPaneId(id, isPlugin) {
|
|
3681
|
+
const raw = String(id);
|
|
3682
|
+
if (!/^\d+$/.test(raw)) return raw;
|
|
3683
|
+
return isPlugin === true ? `plugin_${raw}` : `terminal_${raw}`;
|
|
3684
|
+
}
|
|
3685
|
+
/**
|
|
3686
|
+
* The session's panes — never throws on bad output, and never reports a LOST reply as an empty
|
|
3687
|
+
* session. A read that did not come back as a pane array is simply re-asked (`LIST_PANES_ATTEMPTS`);
|
|
3688
|
+
* only a session that refuses every time answers `[]`, which is then a real answer rather than a
|
|
3689
|
+
* dropped one. See `LIST_PANES_ATTEMPTS` for the defect this stands in front of.
|
|
3690
|
+
*/
|
|
3691
|
+
function listZellijPanes(exec) {
|
|
3692
|
+
for (let attempt = 1; attempt <= LIST_PANES_ATTEMPTS; attempt++) {
|
|
3693
|
+
const panes = readZellijPanes(exec);
|
|
3694
|
+
if (panes) return panes;
|
|
3695
|
+
}
|
|
3696
|
+
return [];
|
|
3697
|
+
}
|
|
3698
|
+
/**
|
|
3699
|
+
* The panes standing right now, keyed the way `samePane` compares them — an open's BEFORE side. A
|
|
3700
|
+
* pane already in this set cannot be the one the open just made, which is the whole check.
|
|
3701
|
+
*/
|
|
3702
|
+
function paneIdSet(exec) {
|
|
3703
|
+
return new Set(listZellijPanes(exec).map((p) => normalizePaneId(p.id)));
|
|
3704
|
+
}
|
|
3705
|
+
/**
|
|
3706
|
+
* The pane THE CLIENT is on, or `undefined` when nothing answers — the restore target an open that
|
|
3707
|
+
* moved focus has to land back on.
|
|
3708
|
+
*
|
|
3709
|
+
* `zellij action list-clients` prints a header row and then one row per client; the second
|
|
3710
|
+
* whitespace-separated column is the pane id, in the `terminal_N`/`plugin_N` form `focus-pane-id`
|
|
3711
|
+
* accepts. The FIRST client row is taken: this adapter is single-client throughout (`MuxTarget`
|
|
3712
|
+
* carries no client qualifier any more than it carries a session one), and with several attached
|
|
3713
|
+
* there is no "the caller's client" for it to prefer.
|
|
3714
|
+
*
|
|
3715
|
+
* `undefined` on an empty or unparseable answer — a session no client has ever attached to reports
|
|
3716
|
+
* exactly that. Nothing to restore then, and nothing was stolen either, so the caller issues no
|
|
3717
|
+
* focus verb rather than inventing one.
|
|
3718
|
+
*/
|
|
3719
|
+
function clientPane(exec) {
|
|
3720
|
+
return (exec("zellij", ["action", "list-clients"]) ?? "").split("\n")[1]?.trim().split(/\s+/)[1] || void 0;
|
|
3721
|
+
}
|
|
3722
|
+
/**
|
|
3723
|
+
* The TERMINAL panes that appeared since `before` — the only candidates an open can have made.
|
|
3724
|
+
*
|
|
3725
|
+
* Plugin panes are excluded because zellij loads them on its own schedule, not the caller's: a tab
|
|
3726
|
+
* opened by `new-tab` can be carrying a plugin pane in the same listing as its own initial pane, and
|
|
3727
|
+
* that plugin record can sort FIRST. Resolving an open to it hands the caller a `plugin_N` — an id
|
|
3728
|
+
* that exists, so nothing downstream refuses it, and that then answers nothing a pane is asked. Seen
|
|
3729
|
+
* at the real boundary: an `open()` at `tab` returned `plugin_15`, and the `rename()` that followed
|
|
3730
|
+
* renamed a pane the caller never opened. `new-tab` and `new-pane` both create a TERMINAL pane, so
|
|
3731
|
+
* the kind is the discriminator, and `is_plugin` is the field that carries it.
|
|
3732
|
+
*/
|
|
3733
|
+
function appearedTerminals(exec, before) {
|
|
3734
|
+
return listZellijPanes(exec).filter((p) => p.is_plugin !== true && !before.has(normalizePaneId(p.id)));
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* The `OpenedPane` for a pane `new-pane` just made — resolved against the listing rather than taken
|
|
3738
|
+
* on the word of the id zellij printed, because that word is not reliable (see
|
|
3739
|
+
* `LIST_PANES_ATTEMPTS`): the reply may be empty, or may be the PREVIOUS command's, in which case
|
|
3740
|
+
* `paneId` names a pane that has been standing all along.
|
|
3741
|
+
*
|
|
3742
|
+
* So the id is believed only where it names a pane ABSENT from `before` — the phantom guard this file
|
|
3743
|
+
* has always meant to be, now closed. Where it cannot be believed, the session itself answers: a
|
|
3744
|
+
* single pane that appeared over this open is unambiguous, and adopting it recovers a reply zellij
|
|
3745
|
+
* lost instead of failing an open that genuinely happened. That fallback waits one round, so a
|
|
3746
|
+
* printed id that is merely SLOW to appear still wins over a guess.
|
|
3747
|
+
*
|
|
3748
|
+
* Throws rather than guessing a tab: `OpenedPane.tab` is required (every multiplexer has the Tab
|
|
3749
|
+
* level), and a wrong tab is worse than a loud failure.
|
|
3750
|
+
*/
|
|
3751
|
+
function openedForPane(exec, paneId, before, session) {
|
|
3752
|
+
for (let attempt = 1; attempt <= OPEN_RESOLVE_ATTEMPTS; attempt++) {
|
|
3753
|
+
const appeared = appearedTerminals(exec, before);
|
|
3754
|
+
const claimed = paneId ? appeared.find((p) => samePane(p.id, paneId)) : void 0;
|
|
3755
|
+
if (claimed?.tab_id != null) return openedPane(paneId, String(claimed.tab_id), session);
|
|
3756
|
+
const only = appeared.length === 1 ? appeared[0] : void 0;
|
|
3757
|
+
if ((attempt > 1 || !paneId) && only?.tab_id != null) return openedPane(only.id, String(only.tab_id), session);
|
|
3758
|
+
}
|
|
3759
|
+
throw new Error(paneId ? `zellij did not report a tab for the new pane ${paneId}` : "zellij action new-pane did not report the new pane id, and no new pane appeared");
|
|
3760
|
+
}
|
|
3761
|
+
/**
|
|
3762
|
+
* The `OpenedPane` for a tab `new-tab` just made — its initial pane resolved as the `list-panes`
|
|
3763
|
+
* record that carries that tab id AND was not already standing. Same reply-delivery defect, same
|
|
3764
|
+
* shape of answer as `openedForPane`: a `tabId` that names a tab whose panes all predate the open is
|
|
3765
|
+
* a stale reply, not this tab, and a single pane that appeared over the open answers when the id
|
|
3766
|
+
* cannot.
|
|
3767
|
+
*
|
|
3768
|
+
* Throws rather than guessing: a new tab must have a pane, and a caller handed a tab with no pane
|
|
3769
|
+
* could neither drive nor name it.
|
|
3770
|
+
*/
|
|
3771
|
+
function openedForTab(exec, tabId, before, session) {
|
|
3772
|
+
for (let attempt = 1; attempt <= OPEN_RESOLVE_ATTEMPTS; attempt++) {
|
|
3773
|
+
const appeared = appearedTerminals(exec, before);
|
|
3774
|
+
const claimed = tabId ? appeared.find((p) => p.tab_id != null && String(p.tab_id) === tabId) : void 0;
|
|
3775
|
+
if (claimed) return openedPane(claimed.id, tabId, session);
|
|
3776
|
+
const only = appeared.length === 1 ? appeared[0] : void 0;
|
|
3777
|
+
if ((attempt > 1 || !tabId) && only?.tab_id != null) return openedPane(only.id, String(only.tab_id), session);
|
|
3778
|
+
}
|
|
3779
|
+
throw new Error(tabId ? `zellij did not report a pane in the new tab ${tabId}` : "zellij action new-tab did not report the new tab id, and no new pane appeared");
|
|
3780
|
+
}
|
|
3781
|
+
/**
|
|
3782
|
+
* Assemble an `OpenedPane`, attaching `workspace` only when the ambient session name is known — the
|
|
3783
|
+
* absent-not-false convention. Every Zellij pane genuinely lives in a session, so where the name IS
|
|
3784
|
+
* known this is never absent, on every placement including `pane:*`; where it is not (the effect-less
|
|
3785
|
+
* singleton), it is omitted rather than reported as a false "none".
|
|
3786
|
+
*/
|
|
3787
|
+
function openedPane(id, tab, session) {
|
|
3788
|
+
const opened = {
|
|
3789
|
+
id,
|
|
3790
|
+
tab
|
|
3791
|
+
};
|
|
3792
|
+
if (session) opened.workspace = session;
|
|
3793
|
+
return opened;
|
|
3794
|
+
}
|
|
3795
|
+
/**
|
|
3796
|
+
* Env is native at NO tier on this backend — `new-pane`/`new-tab` take no `--env`, like wezterm. So
|
|
3797
|
+
* every `open` funnels through the same fallback herdr's worktree route uses: with a launch command,
|
|
3798
|
+
* env rides in as an `env K=V` prefix; with none, a warning names what did not land. `envFallback` is
|
|
3799
|
+
* a no-op when there is no env to carry, so this is safe to call unconditionally.
|
|
3800
|
+
*/
|
|
3801
|
+
function runLaunch(adapter, exec, target, env, launch) {
|
|
3802
|
+
const fallback = envFallback(env, launch);
|
|
3803
|
+
if (fallback.kind === "dropped") {
|
|
3804
|
+
process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this zellij pane — zellij has no --env flag on new-pane/new-tab
|
|
3805
|
+
`);
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
|
|
3809
|
+
}
|
|
3810
|
+
/**
|
|
3811
|
+
* The core key vocabulary's Zellij spelling — a rename table, NOT a validation table: a token outside
|
|
3812
|
+
* the core is forwarded verbatim (the seam's passthrough), so this must not reject what it does not
|
|
3813
|
+
* recognize. Only the two members that differ from the core name are listed; `Up` `Down` `Left`
|
|
3814
|
+
* `Right` `Enter` `Tab` `Space` `Backspace` `F1`–`F12` are already Zellij's own names. Probed from
|
|
3815
|
+
* Zellij's key-binding vocabulary in the docs, not verified against a live `send-keys`:
|
|
3816
|
+
*
|
|
3817
|
+
* - `Escape` → `Esc`: Zellij's key name for that key is `Esc`.
|
|
3818
|
+
* - `C-c` → `Ctrl c`: Zellij writes a modified key as space-separated words (`"Ctrl a"`, `"Alt Shift
|
|
3819
|
+
* b"`), so the whole `Ctrl c` is one argument — the modifier and key are not two keys.
|
|
3820
|
+
*/
|
|
3821
|
+
const ZELLIJ_KEY_RENAMES = {
|
|
3822
|
+
Escape: "Esc",
|
|
3823
|
+
"C-c": "Ctrl c"
|
|
3824
|
+
};
|
|
3825
|
+
function toZellijKey(key) {
|
|
3826
|
+
return ZELLIJ_KEY_RENAMES[key] ?? key;
|
|
3827
|
+
}
|
|
3828
|
+
/**
|
|
3829
|
+
* A Zellij pane's label — its title, unless that title is the running command Zellij handed an unnamed
|
|
3830
|
+
* pane. Zellij defaults an unnamed pane's title to its `terminal_command`, so a title equal to it is
|
|
3831
|
+
* ambient rather than chosen; exporting it would put the same manufactured name on every shell pane,
|
|
3832
|
+
* exactly the collision tmux's hostname guard exists to prevent. A title that differs from the command
|
|
3833
|
+
* is one someone set (`new-pane --name`/`rename-pane`), so it is the author's and survives.
|
|
3834
|
+
*/
|
|
3835
|
+
function zellijLabel(title, command) {
|
|
3836
|
+
return title && title !== command ? title : void 0;
|
|
3837
|
+
}
|
|
3838
|
+
/**
|
|
3839
|
+
* Normalize a pane id to a single canonical form for comparison. Per the Zellij docs a bare integer
|
|
3840
|
+
* `N` is the same terminal pane as `terminal_N`; `plugin_N` is a distinct space. So a bare id is
|
|
3841
|
+
* folded to its `terminal_` form and everything else is left as-is — a comparison-only transform,
|
|
3842
|
+
* never stored (`MuxTarget.id` stays whatever the backend reported).
|
|
3843
|
+
*/
|
|
3844
|
+
function normalizePaneId(id) {
|
|
3845
|
+
return /^\d+$/.test(id) ? `terminal_${id}` : id;
|
|
3846
|
+
}
|
|
3847
|
+
/** Whether two pane ids name the same pane, treating a bare `N` and its `terminal_N` twin as equal. */
|
|
3848
|
+
function samePane(a, b) {
|
|
3849
|
+
return normalizePaneId(a) === normalizePaneId(b);
|
|
3850
|
+
}
|
|
3851
|
+
/** The last `n` lines of `text` — `read`'s client-side approximation of a trailing-lines capture. */
|
|
3852
|
+
function lastLines(text, n) {
|
|
3853
|
+
const lines = text.split("\n");
|
|
3854
|
+
return lines.slice(Math.max(0, lines.length - n)).join("\n");
|
|
3855
|
+
}
|
|
3856
|
+
//#endregion
|
|
3857
|
+
//#region src/mux-probe.ts
|
|
3858
|
+
const KNOWN_MUX = [
|
|
3859
|
+
"tmux",
|
|
3860
|
+
"rmux",
|
|
3861
|
+
"herdr",
|
|
3862
|
+
"wezterm",
|
|
3863
|
+
"zellij",
|
|
3864
|
+
"cmux",
|
|
3865
|
+
"otty",
|
|
3866
|
+
"screen",
|
|
3867
|
+
"none"
|
|
3868
|
+
];
|
|
3869
|
+
function isKnownMux(v) {
|
|
3870
|
+
return v != null && KNOWN_MUX.includes(v);
|
|
3871
|
+
}
|
|
3872
|
+
/**
|
|
3873
|
+
* The single source of the mux → per-pane-env-var mapping. tmux exports `$TMUX_PANE`; herdr exports
|
|
3874
|
+
* `$HERDR_PANE_ID` (both in the same `wX:pY`-style namespace); WezTerm exports `$WEZTERM_PANE` in
|
|
3875
|
+
* every pane (its own bare-integer id) — per the issue that requested that backend (#47), the same
|
|
3876
|
+
* fast-path extension `$TMUX_PANE`/`$HERDR_PANE_ID` already get; Zellij exports `$ZELLIJ_PANE_ID` in
|
|
3877
|
+
* every terminal pane (its own `terminal_N`/bare-`N` id) — per the issue that requested this backend
|
|
3878
|
+
* (#46); cmux exports `$CMUX_SURFACE_ID` in every terminal (its surface ref, e.g. `surface:7`) — per
|
|
3879
|
+
* the issue that requested this backend (#48). screen carries no per-pane env var. Both the ancestry
|
|
3880
|
+
* probe and the `currentPane` self-identity helper read the pane through this table so the two never
|
|
3881
|
+
* diverge on which env var a given mux uses.
|
|
3882
|
+
*
|
|
3883
|
+
* **rmux is the one entry whose ORDER against another matters**, and the reason is not cosmetic: an
|
|
3884
|
+
* rmux pane exports `$RMUX_PANE` AND `$TMUX_PANE`, both holding the same `%N` id, because rmux
|
|
3885
|
+
* reimplements the tmux command language and keeps tmux's env contract for anything that reads it
|
|
3886
|
+
* (probed live on rmux 0.10.0 — a pane's env carried `RMUX=<socket>,<pid>,<session>`,
|
|
3887
|
+
* `RMUX_PANE=%1`, `TMUX=<the same triple>`, `TMUX_PANE=%1`, `TERM_PROGRAM=rmux`). So `$TMUX_PANE`
|
|
3888
|
+
* is NOT evidence of tmux, and every read that walks this table must ask rmux BEFORE tmux, or an
|
|
3889
|
+
* rmux session self-identifies as tmux and gets driven with the wrong binary. The table itself is
|
|
3890
|
+
* unordered — `currentPane` and `discoverByAncestry` below each carry the ordering, and each says so.
|
|
3891
|
+
*/
|
|
3892
|
+
const PANE_ENV = {
|
|
3893
|
+
tmux: (env) => env["TMUX_PANE"],
|
|
3894
|
+
rmux: (env) => env["RMUX_PANE"],
|
|
3895
|
+
herdr: (env) => env["HERDR_PANE_ID"],
|
|
3896
|
+
wezterm: (env) => env["WEZTERM_PANE"],
|
|
3897
|
+
zellij: (env) => env["ZELLIJ_PANE_ID"],
|
|
3898
|
+
cmux: (env) => env["CMUX_SURFACE_ID"],
|
|
3899
|
+
otty: (env) => env["OTTY_PANE_ID"]
|
|
3900
|
+
};
|
|
3901
|
+
/**
|
|
3902
|
+
* Resolve THIS session's own pane from env alone (no `ps` walk): the `$CYBER_MUX_PANE` fast-path a
|
|
3903
|
+
* spawn propagates → `$RMUX_PANE` (rmux) → `$TMUX_PANE` (tmux) → `$HERDR_PANE_ID` (herdr) →
|
|
3904
|
+
* `$WEZTERM_PANE` (wezterm) → `$ZELLIJ_PANE_ID` (zellij) → `$CMUX_SURFACE_ID` (cmux) →
|
|
3905
|
+
* `$OTTY_PANE_ID` (otty). Returns the pane tagged with its multiplexer, or undefined when the
|
|
3906
|
+
* session is in no pane-carrying multiplexer. This is the mux-agnostic self-identity key.
|
|
3907
|
+
*
|
|
3908
|
+
* **rmux is asked before tmux, and that order is load-bearing** — an rmux pane sets `$TMUX_PANE`
|
|
3909
|
+
* too (see `PANE_ENV`), so the tmux question is not a question rmux answers `no` to. The reverse
|
|
3910
|
+
* order would report every rmux pane as tmux and hand `resolveMuxAdapter` the wrong binary.
|
|
3911
|
+
*/
|
|
3912
|
+
function currentPane(env) {
|
|
3913
|
+
if (env["CYBER_MUX_PANE"]) return {
|
|
3914
|
+
mux: env["CYBER_MUX"] === "rmux" ? "rmux" : env["CYBER_MUX"] === "herdr" ? "herdr" : env["CYBER_MUX"] === "wezterm" ? "wezterm" : env["CYBER_MUX"] === "zellij" ? "zellij" : env["CYBER_MUX"] === "cmux" ? "cmux" : env["CYBER_MUX"] === "otty" ? "otty" : "tmux",
|
|
3915
|
+
pane: env["CYBER_MUX_PANE"]
|
|
3916
|
+
};
|
|
3917
|
+
const rmux = PANE_ENV.rmux(env);
|
|
3918
|
+
if (rmux) return {
|
|
3919
|
+
mux: "rmux",
|
|
3920
|
+
pane: rmux
|
|
3921
|
+
};
|
|
3922
|
+
const tmux = PANE_ENV.tmux(env);
|
|
3923
|
+
if (tmux) return {
|
|
3924
|
+
mux: "tmux",
|
|
3925
|
+
pane: tmux
|
|
3926
|
+
};
|
|
3927
|
+
const herdr = PANE_ENV.herdr(env);
|
|
3928
|
+
if (herdr) return {
|
|
3929
|
+
mux: "herdr",
|
|
3930
|
+
pane: herdr
|
|
3931
|
+
};
|
|
3932
|
+
const wezterm = PANE_ENV.wezterm(env);
|
|
3933
|
+
if (wezterm) return {
|
|
3934
|
+
mux: "wezterm",
|
|
3935
|
+
pane: wezterm
|
|
3936
|
+
};
|
|
3937
|
+
const zellij = PANE_ENV.zellij(env);
|
|
3938
|
+
if (zellij) return {
|
|
3939
|
+
mux: "zellij",
|
|
3940
|
+
pane: zellij
|
|
3941
|
+
};
|
|
3942
|
+
const cmux = PANE_ENV.cmux(env);
|
|
3943
|
+
if (cmux) return {
|
|
3944
|
+
mux: "cmux",
|
|
3945
|
+
pane: cmux
|
|
3946
|
+
};
|
|
3947
|
+
const otty = PANE_ENV.otty(env);
|
|
3948
|
+
if (otty) return {
|
|
3949
|
+
mux: "otty",
|
|
3950
|
+
pane: otty
|
|
3951
|
+
};
|
|
3952
|
+
}
|
|
3953
|
+
/**
|
|
3954
|
+
* Two-mode multiplexer detection.
|
|
3955
|
+
*
|
|
3956
|
+
* Fast-path: `$CYBER_MUX` (tmux | rmux | herdr | wezterm | zellij | cmux | otty | screen | none) is
|
|
3957
|
+
* trusted outright — this also serves as an OVERRIDE (`=none` forces no-mux even inside a real
|
|
3958
|
+
* multiplexer).
|
|
3959
|
+
* `$CYBER_MUX_PANE` carries the pane id alongside it. Detection RECOGNIZES `screen` (so an override
|
|
3960
|
+
* pinning it, or a real screen ancestor, is reported truthfully rather than silently ignored), but
|
|
3961
|
+
* `screen` is not a drivable backend — `resolveMuxAdapter` rejects it with a reason. Recognition is
|
|
3962
|
+
* not support.
|
|
3963
|
+
*
|
|
3964
|
+
* Discovery (else): walk the process ancestry from `$$` via `ps -o ppid=,comm= -p <pid>`, since the
|
|
3965
|
+
* tool's own shell may not be the human's pane. `$RMUX`/`$TMUX`/`$HERDR_ENV` are NOT trusted alone —
|
|
3966
|
+
* they are used only as a fast-positive hint the ancestry walk falls back to when the walk itself is
|
|
3967
|
+
* inconclusive (e.g. `ps` unavailable), never as a substitute for it.
|
|
3968
|
+
*/
|
|
3969
|
+
function probeMultiplexer(exec, env, opts = {}) {
|
|
3970
|
+
const prefix = opts.envPrefix ?? "CYBER_MUX";
|
|
3971
|
+
const override = env[prefix];
|
|
3972
|
+
const pane = env[`${prefix}_PANE`];
|
|
3973
|
+
if (isKnownMux(override)) return {
|
|
3974
|
+
mux: override,
|
|
3975
|
+
...pane ? { pane } : {},
|
|
3976
|
+
via: "env"
|
|
3977
|
+
};
|
|
3978
|
+
if (opts.discover === false) return {
|
|
3979
|
+
mux: "none",
|
|
3980
|
+
via: "ancestry"
|
|
3981
|
+
};
|
|
3982
|
+
return discoverByAncestry(exec, env);
|
|
3983
|
+
}
|
|
3984
|
+
const MUX_COMM = [
|
|
3985
|
+
{
|
|
3986
|
+
re: /^tmux(:|$)/,
|
|
3987
|
+
mux: "tmux"
|
|
3988
|
+
},
|
|
3989
|
+
{
|
|
3990
|
+
re: /^rmux(-daemon|-server)?(:|$)/,
|
|
3991
|
+
mux: "rmux"
|
|
3992
|
+
},
|
|
3993
|
+
{
|
|
3994
|
+
re: /^herdr(:|$)/,
|
|
3995
|
+
mux: "herdr"
|
|
3996
|
+
},
|
|
3997
|
+
{
|
|
3998
|
+
re: /^wezterm(-gui|-mux-server)?(:|$)/,
|
|
3999
|
+
mux: "wezterm"
|
|
4000
|
+
},
|
|
4001
|
+
{
|
|
4002
|
+
re: /^zellij(:|$)/,
|
|
4003
|
+
mux: "zellij"
|
|
4004
|
+
},
|
|
4005
|
+
{
|
|
4006
|
+
re: /^screen(:|$)/,
|
|
4007
|
+
mux: "screen"
|
|
4008
|
+
}
|
|
4009
|
+
];
|
|
4010
|
+
/** The per-pane env var for a mux, via the shared `PANE_ENV` table; undefined for screen/none. */
|
|
4011
|
+
function paneFor(mux, env) {
|
|
4012
|
+
return mux === "tmux" || mux === "rmux" || mux === "herdr" || mux === "wezterm" || mux === "zellij" || mux === "cmux" ? PANE_ENV[mux](env) : void 0;
|
|
4013
|
+
}
|
|
4014
|
+
/**
|
|
4015
|
+
* An ancestry-discovered probe, OMITTING `pane` when the mux carries none — never carrying it as an
|
|
4016
|
+
* explicit `undefined`, so `MuxProbe.pane` stays an absent-or-present field (the same conditional
|
|
4017
|
+
* shape the `$CYBER_MUX_PANE` fast-path uses above).
|
|
4018
|
+
*/
|
|
4019
|
+
function ancestryProbe(mux, env) {
|
|
4020
|
+
const pane = paneFor(mux, env);
|
|
4021
|
+
return {
|
|
4022
|
+
mux,
|
|
4023
|
+
...pane !== void 0 ? { pane } : {},
|
|
4024
|
+
via: "ancestry"
|
|
4025
|
+
};
|
|
4026
|
+
}
|
|
4027
|
+
const MAX_ANCESTORS = 32;
|
|
4028
|
+
function walkAncestry(exec, env) {
|
|
4029
|
+
let pid = process.pid;
|
|
4030
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4031
|
+
for (let i = 0; i < MAX_ANCESTORS; i++) {
|
|
4032
|
+
if (seen.has(pid)) break;
|
|
4033
|
+
seen.add(pid);
|
|
4034
|
+
const line = exec("ps", [
|
|
4035
|
+
"-o",
|
|
4036
|
+
"ppid=,comm=",
|
|
4037
|
+
"-p",
|
|
4038
|
+
String(pid)
|
|
4039
|
+
]);
|
|
4040
|
+
if (!line) break;
|
|
4041
|
+
const trimmed = line.trim();
|
|
4042
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
4043
|
+
const ppidStr = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
|
|
4044
|
+
const comm = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
|
|
4045
|
+
const ppid = Number.parseInt(ppidStr, 10);
|
|
4046
|
+
for (const entry of MUX_COMM) if (entry.re.test(comm)) return ancestryProbe(entry.mux, env);
|
|
4047
|
+
if (!Number.isFinite(ppid) || ppid <= 1) break;
|
|
4048
|
+
pid = ppid;
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
function discoverByAncestry(exec, env) {
|
|
4052
|
+
const found = walkAncestry(exec, env);
|
|
4053
|
+
if (found) return found;
|
|
4054
|
+
if (env["RMUX"]) return ancestryProbe("rmux", env);
|
|
4055
|
+
if (env["TMUX"]) return ancestryProbe("tmux", env);
|
|
4056
|
+
if (env["HERDR_ENV"]) return ancestryProbe("herdr", env);
|
|
4057
|
+
if (env["WEZTERM_PANE"]) return ancestryProbe("wezterm", env);
|
|
4058
|
+
if (env["ZELLIJ"]) return ancestryProbe("zellij", env);
|
|
4059
|
+
if (env["CMUX_WORKSPACE_ID"]) return ancestryProbe("cmux", env);
|
|
4060
|
+
if (env["OTTY_PANE_ID"]) return ancestryProbe("otty", env);
|
|
4061
|
+
return {
|
|
4062
|
+
mux: "none",
|
|
4063
|
+
via: "ancestry"
|
|
4064
|
+
};
|
|
4065
|
+
}
|
|
4066
|
+
//#endregion
|
|
4067
|
+
//#region src/nudge.ts
|
|
4068
|
+
/** Max flush re-submits after the initial submit, before nudge fails loud. */
|
|
4069
|
+
const DEFAULT_ATTEMPTS = 10;
|
|
4070
|
+
/** Wait after a submit before reading the pane back, in ms. */
|
|
4071
|
+
const DEFAULT_SETTLE_MS = 400;
|
|
4072
|
+
/** Length of the message prefix used as the staged-text needle. */
|
|
4073
|
+
const NEEDLE_LEN = 40;
|
|
4074
|
+
/**
|
|
4075
|
+
* Whether `message` is still sitting staged in `visible`'s input box (not yet submitted). A real
|
|
4076
|
+
* submit scrolls the message into the transcript, leaving the bottom input box empty, so the
|
|
4077
|
+
* needle is absent from the tail; while staged it sits in the bottom input box, so the needle is
|
|
4078
|
+
* present. `visible` being null/empty means the turn can't be confirmed — treated as still staged
|
|
4079
|
+
* so callers keep retrying rather than falsely reporting success.
|
|
4080
|
+
*/
|
|
4081
|
+
function isStaged(visible, message) {
|
|
4082
|
+
if (!visible) return true;
|
|
4083
|
+
const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN);
|
|
4084
|
+
if (needle === "") return true;
|
|
4085
|
+
return visible.split("\n").filter((l) => l.trim() !== "").slice(-5).join(" ").replace(/\s+/g, " ").trim().includes(needle);
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Submit `message` to `target` and verify the peer actually took the turn — a booting harness can
|
|
4089
|
+
* swallow the Enter of the initial `submit`, leaving the text staged unsent while nudge would
|
|
4090
|
+
* otherwise report false success. Types the message exactly once; a swallowed Enter is recovered by
|
|
4091
|
+
* flushing the staged buffer (`adapter.submit` with no text, a bare Enter) — never re-typing the
|
|
4092
|
+
* message — up to a bounded number of attempts. Throws if the turn is never taken within the cap.
|
|
4093
|
+
*
|
|
4094
|
+
* A pane that no longer exists is rejected up front rather than retried: a gone pane and a booting
|
|
4095
|
+
* one both read back empty, so without the liveness probe the retry loop reports a dead peer as
|
|
4096
|
+
* "never took the turn" — a boot-race shape — and buries the real cause.
|
|
4097
|
+
*
|
|
4098
|
+
* **Not built on `waitForOutput`, deliberately.** The two look alike and wait on opposite conditions:
|
|
4099
|
+
* `waitForOutput` returns when a pattern APPEARS anywhere in the snapshot, while nudge returns when the
|
|
4100
|
+
* message DISAPPEARS from the input box at the bottom (`isStaged`) — a negative, position-sensitive
|
|
4101
|
+
* condition the wait primitive cannot express, and one that must not be satisfied by the same text
|
|
4102
|
+
* sitting up in the transcript, which is exactly where a submitted message ends up. Nor is the loop body
|
|
4103
|
+
* the same: nudge does not merely observe between polls, it re-submits, so its "poll" is a corrective
|
|
4104
|
+
* action with its own attempt budget rather than a read. What the two DO share is the liveness rule —
|
|
4105
|
+
* a gone pane throws instead of being reported as a quiet one — and `pollForOutput` adopts it from here.
|
|
4106
|
+
*/
|
|
4107
|
+
async function nudge(adapter, exec, target, message, opts = {}) {
|
|
4108
|
+
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
4109
|
+
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
4110
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
4111
|
+
if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
|
|
4112
|
+
adapter.submit(exec, target, message);
|
|
4113
|
+
await sleep(settleMs);
|
|
4114
|
+
if (!isStaged(adapter.read(exec, target).text, message)) return {
|
|
4115
|
+
taken: true,
|
|
4116
|
+
resubmits: 0
|
|
4117
|
+
};
|
|
4118
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
4119
|
+
adapter.submit(exec, target);
|
|
4120
|
+
await sleep(settleMs);
|
|
4121
|
+
if (!isStaged(adapter.read(exec, target).text, message)) return {
|
|
4122
|
+
taken: true,
|
|
4123
|
+
resubmits: attempt
|
|
4124
|
+
};
|
|
4125
|
+
}
|
|
4126
|
+
throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
|
|
4127
|
+
}
|
|
4128
|
+
//#endregion
|
|
4129
|
+
//#region src/backend.ts
|
|
4130
|
+
/**
|
|
4131
|
+
* Resolve the raw backend for the multiplexer this process is inside, via the two-mode mux probe
|
|
4132
|
+
* (`$CYBER_MUX` fast-path/override, else ancestry discovery from `$$` falling back to the
|
|
4133
|
+
* `$RMUX`/`$TMUX`/`$HERDR_ENV`/`$WEZTERM_PANE`/`$ZELLIJ`/`$CMUX_WORKSPACE_ID` hint when the walk is
|
|
4134
|
+
* inconclusive) — tmux/rmux/herdr/wezterm/zellij/cmux/otty map to their adapters; anything else throws,
|
|
4135
|
+
* because a caller asking to drive panes with no multiplexer has an unmet precondition, and a loud,
|
|
4136
|
+
* actionable failure beats a silent no-op.
|
|
4137
|
+
*
|
|
4138
|
+
* The zellij adapter is bound to the ambient session name (`$ZELLIJ_SESSION_NAME`) at resolution — it
|
|
4139
|
+
* reports that as `OpenedPane.workspace`, so the session name has to come from the SAME `env` the
|
|
4140
|
+
* probe reads. A missing name simply omits `workspace`; see `createZellijAdapter`. The cmux adapter
|
|
4141
|
+
* is similarly bound to the ambient workspace id (`$CMUX_WORKSPACE_ID`).
|
|
4142
|
+
*
|
|
4143
|
+
* `screen` is a KNOWN mux — the probe detects it (ancestry) and honors it as an override (fast-path)
|
|
4144
|
+
* — but it is NOT a drivable backend, so it throws its OWN message naming the reason rather than the
|
|
4145
|
+
* generic "no multiplexer" one. That distinction is the whole point of issue #45: a caller who
|
|
4146
|
+
* pinned `CYBER_MUX=screen` declared a real multiplexer, and telling them "run inside a multiplexer"
|
|
4147
|
+
* would be a lie. The empirical probe behind the rejection is recorded in the ADR log
|
|
4148
|
+
* (`.agents/spec/design/decisions`): GNU Screen addresses its split regions POSITIONALLY (no
|
|
4149
|
+
* per-pane id), and `$WINDOW` is left unset in panes opened via `screen -X`, so a driver-created
|
|
4150
|
+
* pane has no stable identity to send to, read from, self-identify by, or enumerate — the exact
|
|
4151
|
+
* affordance `SessionTarget.id`/`currentPane`/`LivePane.id` are load-bearing on, and the exact one
|
|
4152
|
+
* wezterm (#47) had and screen lacks. Keeping the value KNOWN-but-rejected honors the override's
|
|
4153
|
+
* whole reason to exist: pinning it tells the caller the truth immediately instead of silently
|
|
4154
|
+
* discovering something else.
|
|
4155
|
+
*
|
|
4156
|
+
* Returns the PURE, exec-injected `MuxAdapter` seam — the one whose methods each take an `Exec`.
|
|
4157
|
+
* `resolveMux` binds one of these into a `MuxSession`; this is the lower layer, exported for a caller
|
|
4158
|
+
* that wants to thread its own runner per call rather than bind one. Not in a multiplexer? Gate on
|
|
4159
|
+
* `probeMultiplexer(env).mux !== 'none'` first — that answer never throws.
|
|
4160
|
+
*/
|
|
4161
|
+
function resolveMuxAdapter(env, exec = nodeExec) {
|
|
4162
|
+
const probe = probeMultiplexer(exec, env);
|
|
4163
|
+
if (probe.mux === "tmux") return tmuxMuxAdapter;
|
|
4164
|
+
if (probe.mux === "rmux") return rmuxMuxAdapter;
|
|
4165
|
+
if (probe.mux === "herdr") return herdrMuxAdapter;
|
|
4166
|
+
if (probe.mux === "wezterm") return weztermMuxAdapter;
|
|
4167
|
+
if (probe.mux === "zellij") return createZellijAdapter({ session: env["ZELLIJ_SESSION_NAME"] });
|
|
4168
|
+
if (probe.mux === "cmux") return createCmuxAdapter({ workspace: env["CMUX_WORKSPACE_ID"] });
|
|
4169
|
+
if (probe.mux === "otty") return createOttyAdapter({ window: void 0 });
|
|
4170
|
+
if (probe.mux === "screen") throw new Error("cyber-mux detected GNU Screen, which it cannot drive: Screen addresses its split regions positionally (no per-pane id) and leaves $WINDOW unset in panes opened via `screen -X`, so a pane has no stable identity to send to, read from, or self-identify by. Run inside tmux ($TMUX), rmux ($RMUX set), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), zellij ($ZELLIJ set), or cmux ($CMUX_WORKSPACE_ID set) instead.");
|
|
4171
|
+
throw new Error("cyber-mux requires a session backend — run inside tmux ($TMUX), rmux ($RMUX set), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), zellij ($ZELLIJ set), cmux ($CMUX_WORKSPACE_ID set), or otty ($OTTY_PANE_ID set)");
|
|
4172
|
+
}
|
|
4173
|
+
/**
|
|
4174
|
+
* Resolve the multiplexer this process is inside and return it as a `MuxSession` with `exec` BOUND —
|
|
4175
|
+
* the ergonomic entry point. `deps.exec` (default `nodeExec`) runs the detection probe AND is the
|
|
4176
|
+
* default runner every method binds; a per-call `deps` overrides it.
|
|
4177
|
+
*
|
|
4178
|
+
* Throws when no supported multiplexer is detected (see `resolveMuxAdapter`); gate on
|
|
4179
|
+
* `probeMultiplexer(env).mux !== 'none'` first if a caller runs with-or-without one. For the pure
|
|
4180
|
+
* exec-injected seam, reach for `resolveMuxAdapter` instead.
|
|
4181
|
+
*/
|
|
4182
|
+
function resolveMux(env, deps) {
|
|
4183
|
+
const boundExec = deps?.exec ?? nodeExec;
|
|
4184
|
+
const raw = resolveMuxAdapter(env, boundExec);
|
|
4185
|
+
const pick = (d) => d?.exec ?? boundExec;
|
|
4186
|
+
return {
|
|
4187
|
+
name: raw.name,
|
|
4188
|
+
...raw.canSizeSplits !== void 0 ? { canSizeSplits: raw.canSizeSplits } : {},
|
|
4189
|
+
callerPane: () => callerPane(raw, env),
|
|
4190
|
+
open: (opts, d) => raw.open(pick(d), opts),
|
|
4191
|
+
rename: (target, tier, name, d) => raw.rename(pick(d), target, tier, name),
|
|
4192
|
+
group: (target, group, name, d) => raw.group(pick(d), target, group, name),
|
|
4193
|
+
sendText: (target, text, d) => raw.sendText(pick(d), target, text),
|
|
4194
|
+
sendKeys: (target, keys, d) => raw.sendKeys(pick(d), target, keys),
|
|
4195
|
+
submit: (target, text, d) => raw.submit(pick(d), target, text),
|
|
4196
|
+
read: (target, opts, d) => raw.read(pick(d), target, opts),
|
|
4197
|
+
waitForOutput: (target, opts, d) => raw.waitForOutput(pick(d), target, opts),
|
|
4198
|
+
focus: (target, d) => raw.focus(pick(d), target),
|
|
4199
|
+
teardown: (target, d) => raw.teardown(pick(d), target),
|
|
4200
|
+
paneExists: (target, d) => raw.paneExists(pick(d), target),
|
|
4201
|
+
isPaneFocused: (target, d) => raw.isPaneFocused(pick(d), target),
|
|
4202
|
+
listPanes: (d) => raw.listPanes(pick(d)),
|
|
4203
|
+
nudge: (target, message, opts, d) => nudge(raw, pick(d), target, message, opts),
|
|
4204
|
+
...raw.worktree ? { worktree: bindWorktree(raw.worktree, pick) } : {},
|
|
4205
|
+
...raw.regions ? { regions: bindRegions(raw.regions, pick) } : {},
|
|
4206
|
+
...raw.agentLifecycle ? { agentLifecycle: bindAgentLifecycle(raw.agentLifecycle, pick) } : {}
|
|
4207
|
+
};
|
|
4208
|
+
}
|
|
4209
|
+
function bindAgentLifecycle(agent, pick) {
|
|
4210
|
+
return { waitForState: (target, opts, d) => agent.waitForState(pick(d), target, opts) };
|
|
4211
|
+
}
|
|
4212
|
+
function bindWorktree(wt, pick) {
|
|
4213
|
+
return {
|
|
4214
|
+
createInWorkspace: (opts, d) => wt.createInWorkspace(pick(d), opts),
|
|
4215
|
+
openInWorkspace: (opts, d) => wt.openInWorkspace(pick(d), opts),
|
|
4216
|
+
bindings: (opts, d) => wt.bindings(pick(d), opts),
|
|
4217
|
+
releaseWorkspace: (workspace, d) => wt.releaseWorkspace(pick(d), workspace)
|
|
4218
|
+
};
|
|
4219
|
+
}
|
|
4220
|
+
function bindRegions(regions, pick) {
|
|
4221
|
+
return {
|
|
4222
|
+
describeRegion: (target, d) => regions.describeRegion(pick(d), target),
|
|
4223
|
+
describeWorkspace: (target, d) => regions.describeWorkspace(pick(d), target)
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* This process's own pane, as something `adapter` can address — `MuxOpenOptions.from`'s intended
|
|
4228
|
+
* argument for a `pane:*` open, so a split lands on the caller rather than on whichever pane the
|
|
4229
|
+
* user is looking at (see `from`'s note for why each backend's default gets that wrong).
|
|
4230
|
+
*
|
|
4231
|
+
* `undefined` when this session is in no pane, or in a pane belonging to a *different* multiplexer
|
|
4232
|
+
* than `adapter` drives — that mismatch is reachable (a `$TMUX_PANE` inherited into a herdr pane,
|
|
4233
|
+
* `$CYBER_MUX` overridden to the other backend), and handing one backend the other's pane id would
|
|
4234
|
+
* turn a self-identity mixup into a split of some unrelated pane. Falling back to the backend's own
|
|
4235
|
+
* default is the conservative answer: still possibly the wrong pane, but never a foreign id.
|
|
4236
|
+
*
|
|
4237
|
+
* A `MuxSession` exposes this bound as `mux.callerPane()`; this free form is for the raw seam.
|
|
4238
|
+
*/
|
|
4239
|
+
function callerPane(adapter, env) {
|
|
4240
|
+
const self = currentPane(env);
|
|
4241
|
+
return self && self.mux === adapter.name ? { id: self.pane } : void 0;
|
|
4242
|
+
}
|
|
4243
|
+
//#endregion
|
|
4244
|
+
export { refuseFloatingPane as A, isReadTruncated as C, pollForOutput as D, matchWaitPattern as E, FloatingPanesUnsupportedError as O, capturedRows as S, assertWaitPattern as T, RMUX_WORKSPACE_GROUP_OPTION as _, nudge as a, partition as b, createZellijAdapter as c, weztermMuxAdapter as d, nodeNewId as f, RMUX_TAB_NAME_OPTION as g, tmuxMuxAdapter as h, isStaged as i, envFallback as j, canFloatPanes as k, zellijMuxAdapter as l, TMUX_WORKSPACE_GROUP_OPTION as m, resolveMux as n, currentPane as o, TMUX_TAB_NAME_OPTION as p, resolveMuxAdapter as r, probeMultiplexer as s, callerPane as t, createWeztermAdapter as u, rmuxMuxAdapter as v, DEFAULT_WAIT_POLL_MS as w, FULL_SCROLLBACK_LINES as x, herdrMuxAdapter as y };
|
|
4245
|
+
|
|
4246
|
+
//# sourceMappingURL=backend-AErdtLRB.mjs.map
|