pi-squire 0.11.0 → 0.13.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/src/tmux.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // The tmux operations `delegate` needs, behind an interface so tests can use a
2
2
  // fake. This client talks to tmux with `execFile` and no shell.
3
- import { execFile } from "node:child_process";
3
+ import { execFile, spawn } from "node:child_process";
4
+
5
+ import type { WindowPlacement } from "./window-placement.ts";
4
6
 
5
7
  /** One window to open: its name, working directory, command and environment. */
6
8
  export type WindowSpec = {
@@ -10,8 +12,41 @@ export type WindowSpec = {
10
12
  env: Readonly<Record<string, string>>;
11
13
  };
12
14
 
13
- /** One window as tmux lists it: its id, its name, and whether the program in its pane has exited. */
14
- export type TmuxWindow = { id: string; name: string; exited: boolean };
15
+ /** One window as tmux lists it: its id, its session's name, its name, and whether the program in its pane has exited. */
16
+ export type TmuxWindow = { id: string; session: string; name: string; exited: boolean };
17
+
18
+ /** A window just opened: its id and the name of the session it is in, when known. */
19
+ export type OpenedWindow = { id: string; session: string | undefined };
20
+
21
+ /** The oldest tmux delegate windows need: `new-session -e` and `display-popup` arrived in 3.2. */
22
+ export const MIN_TMUX = { major: 3, minor: 2 } as const;
23
+
24
+ export type TmuxVersion = { text: string; major: number; minor: number };
25
+
26
+ /** Parse `tmux -V` output such as `tmux 3.7b` or `tmux next-3.4`; a build with no number (`tmux master`) counts as current. */
27
+ export function parseTmuxVersion(output: string): TmuxVersion {
28
+ const text = output.trim();
29
+ const match = /(\d+)\.(\d+)/.exec(text);
30
+ if (match === null) return { text, major: Number.POSITIVE_INFINITY, minor: 0 };
31
+ return { text, major: Number(match[1]), minor: Number(match[2]) };
32
+ }
33
+
34
+ /** Refuse a tmux older than `MIN_TMUX`, naming the version found. */
35
+ export async function requireTmuxVersion(tmux: { version(): Promise<TmuxVersion> }): Promise<void> {
36
+ const found = await tmux.version();
37
+ if (found.major < MIN_TMUX.major || (found.major === MIN_TMUX.major && found.minor < MIN_TMUX.minor)) {
38
+ throw new Error(`delegate windows need tmux ${MIN_TMUX.major}.${MIN_TMUX.minor} or later; this is ${found.text}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The companion session for a parent in `parentSession`: its own when that is
44
+ * a companion session (marked by pi-squire when it made it), else the name
45
+ * plus `-squire`. The name alone says nothing: a user's session may end in it.
46
+ */
47
+ export function companionName(parentSession: string, isCompanion: boolean): string {
48
+ return isCompanion ? parentSession : `${parentSession}-squire`;
49
+ }
15
50
 
16
51
  /**
17
52
  * Why a window could not be opened, and whether one may exist anyway:
@@ -19,7 +54,7 @@ export type TmuxWindow = { id: string; name: string; exited: boolean };
19
54
  * Anything else, a timeout or a malformed answer included, is `unknown`.
20
55
  */
21
56
  export class WindowOpenError extends Error {
22
- readonly certainty: "not-opened" | "unknown";
57
+ certainty: "not-opened" | "unknown";
23
58
  constructor(message: string, certainty: "not-opened" | "unknown") {
24
59
  super(message);
25
60
  this.name = "WindowOpenError";
@@ -27,15 +62,45 @@ export class WindowOpenError extends Error {
27
62
  }
28
63
  }
29
64
 
65
+ /** A window as the popup needs it: its id and its name, which titles the popup. */
66
+ export type PopupWindow = { id: string; name: string };
67
+
68
+ /** The session the parent's pane is in: its id (`$N`) and name. */
69
+ export type OwnSession = { id: string; name: string };
70
+
30
71
  export interface TmuxClient {
31
72
  /** Whether this process runs in a tmux pane. */
32
73
  insideTmux(): boolean;
33
- /** Open a detached window and return its id (for example `@3`). */
34
- openWindow(spec: WindowSpec): Promise<string>;
74
+ /** The tmux version, asked once. */
75
+ version(): Promise<TmuxVersion>;
76
+ /**
77
+ * Open a detached window and return its id (for example `@3`) and session.
78
+ * `current` opens it in the parent's session; `companion` in
79
+ * `<parent's session>-squire`, created when it does not exist.
80
+ */
81
+ openWindow(spec: WindowSpec, placement: WindowPlacement): Promise<OpenedWindow>;
35
82
  /** Every window on the server, in one tmux call. */
36
83
  listWindows(): Promise<TmuxWindow[]>;
37
84
  /** Kill a window. */
38
85
  kill(windowId: string): Promise<void>;
86
+ /** The session the parent's pane is in; undefined when it cannot be told. */
87
+ ownSession(): Promise<OwnSession | undefined>;
88
+ /** The most recently active client attached to the session named `session`; undefined when none is. */
89
+ clientFor(session: string): Promise<string | undefined>;
90
+ /** Show the window to a client: `switch-client -c <client> -t @N`. */
91
+ switchTo(client: string, windowId: string): Promise<void>;
92
+ /**
93
+ * Open a popup over a client that attaches to the window's session at the
94
+ * window. `display-popup` returns only when the popup closes, so this
95
+ * resolves once the popup was launched, not when it ends, and a launch that
96
+ * fails at once rejects. `onExit` runs when the popup's process ends, however
97
+ * the popup was closed, and not when the launch failed.
98
+ */
99
+ popup(client: string, window: PopupWindow, onExit: () => void): Promise<void>;
100
+ /** Close the popup on a client (`display-popup -C`). */
101
+ closePopup(client: string): Promise<void>;
102
+ /** The window's visible pane as text (`capture-pane -p`). */
103
+ capture(windowId: string): Promise<string>;
39
104
  }
40
105
 
41
106
  /** Run one tmux command and return its stdout; never a shell. */
@@ -56,64 +121,239 @@ function runTmux(args: string[], timeout: number): Promise<string> {
56
121
  });
57
122
  }
58
123
 
124
+ type Run = (args: string[]) => Promise<string>;
125
+ type Parent = { id: string; name: string; companion: boolean };
126
+
127
+ /** The user option that marks a session pi-squire made for delegate windows. */
128
+ const COMPANION_OPTION = "@squire-companion";
129
+
130
+ /** The id, name and companion mark of the session the pane in `TMUX_PANE` belongs to, when known. */
131
+ async function parentSession(run: Run): Promise<Parent | undefined> {
132
+ const pane = process.env.TMUX_PANE;
133
+ if (pane === undefined || pane === "") return undefined;
134
+ // Only the line's terminator goes: trailing spaces are legal in a session name.
135
+ const format = `#{session_id}\t#{${COMPANION_OPTION}}\t#{session_name}`;
136
+ const out = (await run(["display-message", "-p", "-t", pane, "-F", format])).replace(/\r?\n$/, "");
137
+ // The name comes last, so it is whatever follows the second tab.
138
+ const [id, marker, ...name] = out.split("\t");
139
+ return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t"), companion: marker === "1" };
140
+ }
141
+
142
+ /** How long a launched popup may take to fail before it counts as shown. */
143
+ const POPUP_SETTLE_MS = 300;
144
+
145
+ /**
146
+ * Launch `display-popup`, which blocks until the popup closes, detached and
147
+ * outside `runTmux`'s timeout. Resolves once it is up; rejects on a spawn
148
+ * error or an exit within the settle time. Its stderr is read only until then
149
+ * and then released, so the open popup keeps nothing of this process alive;
150
+ * `onExit` runs when the popup's process ends, however it was closed.
151
+ */
152
+ function launchPopup(args: string[], onExit: () => void): Promise<void> {
153
+ return new Promise((resolve, reject) => {
154
+ let settled = false;
155
+ let stderr = "";
156
+ const child = spawn("tmux", args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
157
+ const onData = (chunk: Buffer): void => {
158
+ if (stderr.length < 4096) stderr += chunk.toString();
159
+ };
160
+ child.stderr?.on("data", onData);
161
+ const settle = (error?: Error): void => {
162
+ if (settled) return;
163
+ settled = true;
164
+ clearTimeout(timer);
165
+ child.stderr?.off("data", onData);
166
+ child.stderr?.destroy();
167
+ if (error === undefined) resolve();
168
+ else reject(error);
169
+ };
170
+ const timer = setTimeout(() => settle(), POPUP_SETTLE_MS);
171
+ child.on("error", (err) => settle(err));
172
+ child.on("close", (code) => {
173
+ if (!settled && code !== 0) {
174
+ settle(new Error(stderr.trim() !== "" ? stderr.trim() : `tmux display-popup exited with ${code}`));
175
+ return;
176
+ }
177
+ settle();
178
+ onExit();
179
+ });
180
+ child.unref();
181
+ });
182
+ }
183
+
184
+ const envArgs = (spec: WindowSpec): string[] =>
185
+ Object.entries(spec.env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
186
+
187
+ /** Dispatch one create; after dispatch even an error or timeout may have left a window. */
188
+ async function create(run: Run, args: string[]): Promise<string> {
189
+ let out: string;
190
+ try {
191
+ out = await run(args);
192
+ } catch (err) {
193
+ throw new WindowOpenError((err as Error).message, "unknown");
194
+ }
195
+ const windowId = out.trim();
196
+ if (!/^@[0-9]+$/.test(windowId)) throw new WindowOpenError("tmux reported an invalid window id", "unknown");
197
+ return windowId;
198
+ }
199
+
200
+ const failedWith = (err: unknown, text: string): boolean =>
201
+ err instanceof WindowOpenError && err.message.includes(text);
202
+
203
+ /**
204
+ * Open a window in the companion session: `new-window`, then on a missing
205
+ * session one `new-session` that makes the delegate its first window and sets
206
+ * `detach-on-destroy off` and the companion mark on that session only, then `new-window` again when
207
+ * another launch created the session first.
208
+ */
209
+ async function openCompanion(run: Run, spec: WindowSpec, session: string): Promise<string> {
210
+ const newWindow = [
211
+ "new-window",
212
+ "-d",
213
+ "-P",
214
+ "-F",
215
+ "#{window_id}",
216
+ "-n",
217
+ spec.name,
218
+ "-c",
219
+ spec.cwd,
220
+ ...envArgs(spec),
221
+ "-t",
222
+ `=${session}:`,
223
+ "--",
224
+ ...spec.argv,
225
+ ];
226
+ try {
227
+ return await create(run, newWindow);
228
+ } catch (err) {
229
+ // tmux resolves the target before it creates anything, so nothing was dispatched that could create a window.
230
+ if (!failedWith(err, "can't find session")) throw err;
231
+ (err as WindowOpenError).certainty = "not-opened";
232
+ }
233
+ const newSession = ["new-session", "-d", "-s", session, "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
234
+ newSession.push("-P", "-F", "#{window_id}", "--", ...spec.argv);
235
+ newSession.push(";", "set-option", "-t", `=${session}:`, "detach-on-destroy", "off");
236
+ newSession.push(";", "set-option", "-t", `=${session}:`, COMPANION_OPTION, "1");
237
+ try {
238
+ return await create(run, newSession);
239
+ } catch (err) {
240
+ if (!failedWith(err, "duplicate session")) throw err;
241
+ }
242
+ return create(run, newWindow);
243
+ }
244
+
245
+ /** Open a window in the parent's own session, as delegates always did. */
246
+ function openCurrent(run: Run, spec: WindowSpec, parent: Parent | undefined): Promise<string> {
247
+ const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
248
+ if (parent !== undefined) args.push("-t", parent.id);
249
+ args.push("--", ...spec.argv);
250
+ return create(run, args);
251
+ }
252
+
253
+ /** The methods that reach a delegate's window from the user's terminal: clients, switch, popup, capture. */
254
+ function navigation(
255
+ run: Run,
256
+ ): Pick<TmuxClient, "ownSession" | "clientFor" | "switchTo" | "popup" | "closePopup" | "capture"> {
257
+ return {
258
+ async ownSession() {
259
+ const parent = await parentSession(run);
260
+ return parent === undefined || parent.name === "" ? undefined : { id: parent.id, name: parent.name };
261
+ },
262
+
263
+ async clientFor(session) {
264
+ const out = await run(["list-clients", "-t", `=${session}:`, "-F", "#{client_activity}\t#{client_name}"]);
265
+ let best: { activity: number; name: string } | undefined;
266
+ for (const line of out.split("\n")) {
267
+ const [activity, ...name] = line.split("\t");
268
+ const at = Number(activity);
269
+ if (name.length === 0 || Number.isNaN(at)) continue;
270
+ if (best === undefined || at > best.activity) best = { activity: at, name: name.join("\t") };
271
+ }
272
+ return best?.name;
273
+ },
274
+
275
+ async switchTo(client, windowId) {
276
+ await run(["switch-client", "-c", client, "-t", windowId]);
277
+ },
278
+
279
+ async popup(client, window, onExit) {
280
+ if (!/^@[0-9]+$/.test(window.id)) throw new Error(`${window.id} is not a tmux window id`);
281
+ const sessionId = (await run(["display-message", "-p", "-t", window.id, "-F", "#{session_id}"])).trim();
282
+ if (!/^\$[0-9]+$/.test(sessionId)) throw new Error(`tmux reported an invalid session id for ${window.id}`);
283
+ // Ids are `@N` and `$N` only; quoted so the popup's shell does not expand `$N`.
284
+ const command = `tmux select-window -t '${window.id}' \\; attach-session -t '${sessionId}'`;
285
+ await launchPopup(
286
+ ["display-popup", "-c", client, "-E", "-w", "90%", "-h", "90%", "-T", window.name, command],
287
+ onExit,
288
+ );
289
+ },
290
+
291
+ async closePopup(client) {
292
+ await run(["display-popup", "-C", "-c", client]);
293
+ },
294
+
295
+ async capture(windowId) {
296
+ return run(["capture-pane", "-p", "-t", windowId]);
297
+ },
298
+ };
299
+ }
300
+
59
301
  /**
60
- * The real client. A window is opened in the session of the pane in
61
- * `TMUX_PANE`, not in whatever session a client happens to have attached, so
62
- * it works with several clients on one server.
302
+ * The real client. A window is opened relative to the session of the pane in
303
+ * `TMUX_PANE`, not whatever session a client happens to have attached, so it
304
+ * works with several clients on one server.
63
305
  */
64
306
  export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClient {
65
307
  const timeoutMs = options.timeoutMs ?? 10_000;
66
- const run = (args: string[]) => runTmux(args, timeoutMs);
67
-
68
- /** The id of the session this pane belongs to, when known. */
69
- async function targetSession(): Promise<string | undefined> {
70
- const pane = process.env.TMUX_PANE;
71
- if (pane === undefined || pane === "") return undefined;
72
- const sessionId = (await run(["display-message", "-p", "-t", pane, "-F", "#{session_id}"])).trim();
73
- return sessionId === "" ? undefined : sessionId;
74
- }
308
+ const run: Run = (args) => runTmux(args, timeoutMs);
309
+ let cachedVersion: Promise<TmuxVersion> | undefined;
75
310
 
76
311
  return {
77
312
  insideTmux: () => (process.env.TMUX ?? "") !== "",
78
313
 
79
- async openWindow(spec) {
80
- const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd];
81
- for (const [key, value] of Object.entries(spec.env)) args.push("-e", `${key}=${value}`);
82
- let session: string | undefined;
314
+ version() {
315
+ // The in-flight ask is shared; a failed one is forgotten so the next call asks again.
316
+ cachedVersion ??= run(["-V"]).then(parseTmuxVersion, (err: unknown) => {
317
+ cachedVersion = undefined;
318
+ throw err;
319
+ });
320
+ return cachedVersion;
321
+ },
322
+
323
+ async openWindow(spec, placement) {
324
+ let parent: Parent | undefined;
83
325
  try {
84
- session = await targetSession();
326
+ parent = await parentSession(run);
85
327
  } catch (err) {
86
328
  // The lookup only reads; no window can exist yet.
87
329
  throw new WindowOpenError((err as Error).message, "not-opened");
88
330
  }
89
- if (session !== undefined) args.push("-t", session);
90
- args.push("--", ...spec.argv);
91
- let out: string;
92
- try {
93
- out = await run(args);
94
- } catch (err) {
95
- // After dispatch even an error or timeout may have left a window.
96
- throw new WindowOpenError((err as Error).message, "unknown");
331
+ const known = parent === undefined || parent.name === "" ? undefined : parent.name;
332
+ if (placement === "current") return { id: await openCurrent(run, spec, parent), session: known };
333
+ if (known === undefined) {
334
+ throw new WindowOpenError("could not tell which tmux session this pane is in (TMUX_PANE)", "not-opened");
97
335
  }
98
- const windowId = out.trim();
99
- if (!/^@[0-9]+$/.test(windowId)) throw new WindowOpenError("tmux reported an invalid window id", "unknown");
100
- return windowId;
336
+ const session = companionName(known, parent?.companion === true);
337
+ return { id: await openCompanion(run, spec, session), session };
101
338
  },
102
339
 
103
340
  async listWindows() {
104
- // The name goes last: it is the one field that could hold a tab.
105
- const out = await run(["list-windows", "-a", "-F", "#{window_id}\t#{pane_dead}\t#{window_name}"]);
341
+ // The window name goes last: it is the one field that could hold a tab.
342
+ const format = "#{window_id}\t#{pane_dead}\t#{session_name}\t#{window_name}";
343
+ const out = await run(["list-windows", "-a", "-F", format]);
106
344
  return out
107
345
  .split("\n")
108
346
  .filter((line) => line.trim() !== "")
109
347
  .map((line) => {
110
- const [id, dead, ...name] = line.split("\t");
111
- return { id, name: name.join("\t"), exited: dead === "1" };
348
+ const [id, dead, session, ...name] = line.split("\t");
349
+ return { id, session, name: name.join("\t"), exited: dead === "1" };
112
350
  });
113
351
  },
114
352
 
115
353
  async kill(windowId) {
116
354
  await run(["kill-window", "-t", windowId]);
117
355
  },
356
+
357
+ ...navigation(run),
118
358
  };
119
359
  }