pi-squire 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.14.0] - 2026-10-27
10
+
11
+ ### Removed
12
+
13
+ - The delegate popup: `enter` in `/delegates` and `/delegate-open --popup` no
14
+ longer open a tmux popup, and the popup title from 0.13.1 (the close key in
15
+ its title) goes with it. `--popup` is now an unknown-flag usage error. The
16
+ poll runs again only while a delegation is running. The close hint was hard to
17
+ see in some themes, and a switch covers the need.
18
+
19
+ ### Changed
20
+
21
+ - `enter` in the `/delegates` panel, and in its peek, now switches your tmux
22
+ client to the delegate's window (the notice says your tmux last-session key returns you); `/delegate-open
23
+ <id>` with no flag switches too. The `s` key is gone. With no client attached,
24
+ switch still refuses; peek and close still work. tmux 3.2 stays the minimum,
25
+ for `new-session -e`.
26
+ - Tests changed with it: in `test/navigator.test.ts` the panel hint, `enter`,
27
+ peek, no-client, failure, `/delegate-open` parsing, default and completion
28
+ tests now expect a switch, and the popup lifecycle tests are replaced by one
29
+ test that the poll runs only while a delegation does; `test/tmux.test.ts` no
30
+ longer tests `popup` and `closePopup`, and `test/popup-parent.ts` and the
31
+ fake's popup support are gone.
32
+
33
+ ## [0.13.1] - 2026-10-27
34
+
35
+ ### Changed
36
+
37
+ - A delegate's popup title names the key that closes it, with your tmux prefix,
38
+ for example `scout-first · C-b d to close`.
39
+
9
40
  ## [0.13.0] - 2026-10-26
10
41
 
11
42
  ### Added
@@ -513,7 +544,9 @@ author's dotfiles; the changes below are against that copy.
513
544
 
514
545
  - The published file list includes `child.ts`, which `index.ts` imports.
515
546
 
516
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.13.0...HEAD
547
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.14.0...HEAD
548
+ [0.14.0]: https://github.com/gvanderclay/pi-squire/compare/v0.13.1...v0.14.0
549
+ [0.13.1]: https://github.com/gvanderclay/pi-squire/compare/v0.13.0...v0.13.1
517
550
  [0.13.0]: https://github.com/gvanderclay/pi-squire/compare/v0.12.0...v0.13.0
518
551
  [0.12.0]: https://github.com/gvanderclay/pi-squire/compare/v0.11.0...v0.12.0
519
552
  [0.11.0]: https://github.com/gvanderclay/pi-squire/compare/v0.10.0...v0.11.0
package/README.md CHANGED
@@ -17,7 +17,7 @@ You need:
17
17
  on. Pi supplies the peer dependencies `@earendil-works/pi-coding-agent` and
18
18
  `typebox`.
19
19
  - tmux 3.2 or later, with the Pi session running in a tmux pane. That is the
20
- first version with `new-session -e` and `display-popup`. Windows are placed
20
+ first version with `new-session -e`. Windows are placed
21
21
  relative to the pane's session (see [Where delegate windows
22
22
  open](#where-delegate-windows-open)), so a client attached elsewhere does
23
23
  not matter.
@@ -188,24 +188,21 @@ or done with auto-exit off, newest first. The model has no tool for any of this.
188
188
  | Key | Does |
189
189
  |---|---|
190
190
  | `↑` / `↓` | select a delegation; `esc` leaves the panel |
191
- | `enter` | open: a popup over your terminal, or a switch when the window is in the parent's own session |
192
- | `p` | peek: a snapshot of the window's pane (`capture-pane`) in an overlay; `r` refreshes, `enter` opens, `esc` returns to the list |
193
- | `s` | switch your tmux client to the window; `prefix L` returns you (tmux's last-session key) |
191
+ | `enter` | switch your tmux client to the window |
192
+ | `p` | peek: a snapshot of the window's pane (`capture-pane`) in an overlay; `r` refreshes, `enter` switches, `esc` returns to the list |
194
193
  | `x` | close the delegation as `delegation_close` does; asks first only while it is running |
195
194
  | `n` | new delegate: the same form `/delegate` opens with no arguments (see [First use](#first-use)) |
196
195
 
197
- Popup and switch act on the most recently active tmux client attached to the
198
- parent's session. With none attached they refuse with a notice; peek and close
199
- still work. The popup attaches to the window's session at that window, and
200
- detaching in it (`prefix d`) closes it. When the delegate's window goes, the parent
201
- closes the popup within one poll (up to 5 s). No tmux key bindings or options are
202
- added.
196
+ Switch acts on the most recently active tmux client attached to the parent's
197
+ session. With none attached it refuses with a notice; peek and close still
198
+ work. No tmux key bindings or options are added.
203
199
 
204
- `/delegate-open <id> [--popup|--switch|--peek]` does one of these on one
205
- delegation without the panel: with no flag or with `--popup`, what `enter` does (a popup, or a
206
- switch for a window in the parent's own session, since a popup of one's own session would show it
207
- inside itself); `--switch` always switches; `--peek` opens the peek overlay. Ids complete from the
208
- open delegations.
200
+ To come back, use your own tmux last-session key (tmux's default is `prefix L`,
201
+ often rebound); the notice after a switch says so.
202
+
203
+ `/delegate-open <id> [--switch|--peek]` does one of these on one delegation
204
+ without the panel: with no flag or with `--switch`, it switches; `--peek` opens
205
+ the peek overlay. Ids complete from the open delegations.
209
206
 
210
207
  ## Working directory
211
208
 
@@ -1048,7 +1045,7 @@ Other extensions may rely on:
1048
1045
  no-argument forms: `/delegate` opens the new-delegate form and
1049
1046
  `/delegate-resume` the resume picker;
1050
1047
  - the `/delegates` command and its `alt+shift+d` shortcut, and `/delegate-open <id>` with its
1051
- `--popup`, `--switch` and `--peek` flags;
1048
+ `--switch` and `--peek` flags;
1052
1049
  - the `[delegate]` result text and the closed-without-a-result text;
1053
1050
  - `AGENT.md`'s frontmatter keys and its location,
1054
1051
  `<agent dir>/squire/agents/<name>/AGENT.md`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -1284,7 +1284,7 @@ export default function delegate(
1284
1284
  );
1285
1285
 
1286
1286
  pi.registerCommand("delegates", {
1287
- description: "Open the panel of delegates whose window is open: popup, peek, switch, close",
1287
+ description: "Open the panel of delegates whose window is open: switch, peek, close",
1288
1288
  handler: async (_args: string, ctx: ExtensionContext) => navigator.panel(ctx),
1289
1289
  });
1290
1290
 
package/src/navigator.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  // The delegate navigator: the `/delegates` panel and `/delegate-open`. It lists
2
- // the delegations whose window is open and reaches one of them: a popup over
3
- // the user's tmux client, a switch of that client, a peek at the pane, or a
4
- // close. Everything here is a command or a key; the model gets no tool that
2
+ // the delegations whose window is open and reaches one of them: a switch of
3
+ // the user's tmux client to its window, a peek at the pane, or a close. Everything here is a command or a key; the model gets no tool that
5
4
  // moves the user's terminal.
6
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
6
  import { type Component, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
@@ -10,11 +9,11 @@ import { type DelegationState, deriveState, isStarted, type Results, recordedNam
10
9
  import type { TmuxClient, TmuxWindow } from "./tmux.ts";
11
10
  import { listWindows, ownWindow, type Tracking } from "./tracking.ts";
12
11
 
13
- /** What `/delegate-open` does: `auto` is what enter does in the panel. */
14
- export type OpenMode = "auto" | "popup" | "switch" | "peek";
12
+ /** What `/delegate-open` does; switch is what enter does in the panel. */
13
+ export type OpenMode = "switch" | "peek";
15
14
 
16
- export const OPEN_USAGE = "/delegate-open <id> [--popup|--switch|--peek]";
17
- const OPEN_FLAGS = ["--popup", "--switch", "--peek"] as const;
15
+ export const OPEN_USAGE = "/delegate-open <id> [--switch|--peek]";
16
+ const OPEN_FLAGS = ["--switch", "--peek"] as const;
18
17
 
19
18
  /** One delegation whose window is open. */
20
19
  type OpenDelegate = {
@@ -26,14 +25,14 @@ type OpenDelegate = {
26
25
  };
27
26
 
28
27
  /** What the panel hands back: the action the user chose on one delegation, or none. */
29
- type PanelResult = { action: "open" | "switch" | "close" | "peek"; id: string } | { action: "new" } | undefined;
28
+ type PanelResult = { action: "switch" | "close" | "peek"; id: string } | { action: "new" } | undefined;
30
29
 
31
- /** What the peek hands back: open the delegate, or nothing. */
32
- type PeekResult = { action: "open" } | undefined;
30
+ /** What the peek hands back: switch to the delegate, or nothing. */
31
+ type PeekResult = { action: "switch" } | undefined;
33
32
 
34
- const HINT = "↑↓ select · enter open · p peek · s switch · x close · n new · esc leave";
33
+ const HINT = "↑↓ select · enter switch · p peek · x close · n new · esc leave";
35
34
  const EMPTY = "No open delegates · n new · esc close";
36
- const PEEK_HINT = "r refresh · enter open · esc back";
35
+ const PEEK_HINT = "r refresh · enter switch · esc back";
37
36
 
38
37
  /** Where a peek shows: over the panel or the editor, not in its place. */
39
38
  const PEEK_OPTIONS = { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%", anchor: "center" } } as const;
@@ -82,9 +81,8 @@ class Panel implements Component {
82
81
  else if (item === undefined) return;
83
82
  else if (matchesKey(data, "up")) this.selected = (this.selected + count - 1) % count;
84
83
  else if (matchesKey(data, "down")) this.selected = (this.selected + 1) % count;
85
- else if (matchesKey(data, "enter")) this.done({ action: "open", id: item.id });
84
+ else if (matchesKey(data, "enter")) this.done({ action: "switch", id: item.id });
86
85
  else if (matchesKey(data, "p")) this.done({ action: "peek", id: item.id });
87
- else if (matchesKey(data, "s")) this.done({ action: "switch", id: item.id });
88
86
  else if (matchesKey(data, "x")) this.done({ action: "close", id: item.id });
89
87
  this.redraw();
90
88
  }
@@ -101,7 +99,7 @@ class Panel implements Component {
101
99
  }
102
100
  }
103
101
 
104
- /** A snapshot of one delegate's pane, shown as an overlay: `r` refreshes, enter opens, esc returns. */
102
+ /** A snapshot of one delegate's pane, shown as an overlay: `r` refreshes, enter switches, esc returns. */
105
103
  class PeekView implements Component {
106
104
  private capture: Capture = { text: "", error: undefined, loading: true };
107
105
  private readonly item: OpenDelegate;
@@ -147,7 +145,7 @@ class PeekView implements Component {
147
145
  handleInput(data: string): void {
148
146
  if (matchesKey(data, "escape")) this.done(undefined);
149
147
  else if (matchesKey(data, "r")) this.refresh();
150
- else if (matchesKey(data, "enter")) this.done({ action: "open" });
148
+ else if (matchesKey(data, "enter")) this.done({ action: "switch" });
151
149
  this.redraw();
152
150
  }
153
151
 
@@ -176,10 +174,10 @@ export function parseOpen(text: string): { id: string; mode: OpenMode } | { erro
176
174
  const ids = words.filter((word) => !word.startsWith("-"));
177
175
  const unknown = flags.find((flag) => !(OPEN_FLAGS as readonly string[]).includes(flag));
178
176
  if (unknown !== undefined) return { error: `unknown flag ${unknown}; ${OPEN_USAGE}` };
179
- if (flags.length > 1) return { error: `give at most one of --popup, --switch and --peek; ${OPEN_USAGE}` };
177
+ if (flags.length > 1) return { error: `give at most one of --switch and --peek; ${OPEN_USAGE}` };
180
178
  if (ids.length !== 1) return { error: `give one delegation id; ${OPEN_USAGE}` };
181
179
  const flag = flags[0];
182
- return { id: ids[0], mode: flag === undefined ? "auto" : (flag.slice(2) as OpenMode) };
180
+ return { id: ids[0], mode: flag === undefined ? "switch" : (flag.slice(2) as OpenMode) };
183
181
  }
184
182
 
185
183
  /** Completions for `/delegate-open`: ids first, then the mode flags not yet given. */
@@ -194,7 +192,7 @@ export function openCompletions(prefix: string, ids: readonly string[]): { value
194
192
  return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
195
193
  }
196
194
 
197
- type Env = { results: Results; tmux: TmuxClient; tracking: Pick<Tracking, "trackPopup" | "close"> };
195
+ type Env = { results: Results; tmux: TmuxClient; tracking: Pick<Tracking, "close"> };
198
196
 
199
197
  /** The open delegations, newest first, from the records and one listing; undefined when tmux cannot be asked. */
200
198
  async function openDelegates({ results, tmux }: Env): Promise<OpenDelegate[] | undefined> {
@@ -233,24 +231,6 @@ async function clientOf(tmux: TmuxClient): Promise<{ client: string } | { refusa
233
231
  }
234
232
  }
235
233
 
236
- async function popup(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
237
- const found = await clientOf(env.tmux);
238
- if ("refusal" in found) return ctx.ui.notify(`delegate: cannot open a popup: ${found.refusal}`, "warning");
239
- // Tracked before it launches, so a popup that closes at once is forgotten too.
240
- const forget = env.tracking.trackPopup({
241
- windowId: item.window.id,
242
- windowName: item.window.name,
243
- client: found.client,
244
- });
245
- try {
246
- // Resolves once the popup is up, not when it closes; `forget` runs when it closes.
247
- await env.tmux.popup(found.client, { id: item.window.id, name: item.window.name }, forget);
248
- } catch (err) {
249
- forget();
250
- return ctx.ui.notify(`delegate: could not open a popup: ${(err as Error).message}`, "error");
251
- }
252
- }
253
-
254
234
  async function switchTo(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
255
235
  const found = await clientOf(env.tmux);
256
236
  if ("refusal" in found) return ctx.ui.notify(`delegate: cannot switch: ${found.refusal}`, "warning");
@@ -259,13 +239,10 @@ async function switchTo(env: Env, ctx: ExtensionContext, item: OpenDelegate): Pr
259
239
  } catch (err) {
260
240
  return ctx.ui.notify(`delegate: could not switch: ${(err as Error).message}`, "error");
261
241
  }
262
- ctx.ui.notify(`delegate: switched to ${item.name} (${item.window.id}) · prefix L returns you`, "info");
263
- }
264
-
265
- /** Enter's behaviour: a switch inside the parent's own session, else a popup. */
266
- async function open(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
267
- const own = await ownSessionName(env.tmux);
268
- return own !== undefined && own === item.window.session ? switchTo(env, ctx, item) : popup(env, ctx, item);
242
+ ctx.ui.notify(
243
+ `delegate: switched to ${item.name} (${item.window.id}) · your tmux last-session key returns you`,
244
+ "info",
245
+ );
269
246
  }
270
247
 
271
248
  /** Close through the `delegation_close` path, asking first only while the delegate runs. */
@@ -294,7 +271,7 @@ function show(ctx: ExtensionContext, items: readonly OpenDelegate[]): Promise<Pa
294
271
  );
295
272
  }
296
273
 
297
- /** Peek at a delegate's pane in an overlay; true when the user chose to open it. */
274
+ /** Peek at a delegate's pane in an overlay; true when the user chose to switch to it. */
298
275
  async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<boolean> {
299
276
  const result = await ctx.ui.custom<PeekResult>(
300
277
  (tui, theme, _keybindings, done): PeekView =>
@@ -308,7 +285,7 @@ async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promis
308
285
  }),
309
286
  PEEK_OPTIONS,
310
287
  );
311
- return result?.action === "open";
288
+ return result?.action === "switch";
312
289
  }
313
290
 
314
291
  const noUi = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: this needs an interactive session", "error");
@@ -335,10 +312,10 @@ async function act(
335
312
  }
336
313
  if (result.action === "peek") {
337
314
  if (!(await peek(env, ctx, item))) return true; // back to the panel
338
- await open(env, ctx, item);
315
+ await switchTo(env, ctx, item);
339
316
  return false;
340
317
  }
341
- await (result.action === "switch" ? switchTo(env, ctx, item) : open(env, ctx, item));
318
+ await switchTo(env, ctx, item);
342
319
  return false;
343
320
  }
344
321
 
@@ -352,16 +329,15 @@ async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMo
352
329
  return ctx.ui.notify(`delegate: no open delegate ${JSON.stringify(id)}; open: ${known}`, "error");
353
330
  }
354
331
  if (mode === "switch") return switchTo(env, ctx, item);
355
- if (mode !== "peek") return open(env, ctx, item); // --popup follows enter's rule too
356
332
  if (!ctx.hasUI) return noUi(ctx);
357
- if (await peek(env, ctx, item)) await open(env, ctx, item);
333
+ if (await peek(env, ctx, item)) await switchTo(env, ctx, item);
358
334
  }
359
335
 
360
336
  /** The navigator over the records, tmux and tracking. `startNew` is the new-delegate flow, wired by the entry. */
361
337
  export function createNavigator(
362
338
  results: Results,
363
339
  tmux: TmuxClient,
364
- tracking: Pick<Tracking, "trackPopup" | "close">,
340
+ tracking: Pick<Tracking, "close">,
365
341
  startNew: (ctx: ExtensionContext) => Promise<void> | void,
366
342
  ): Navigator {
367
343
  const env: Env = { results, tmux, tracking };
package/src/poll.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  // The poll: every few seconds the parent checks its running delegations,
2
- // declares gone those whose window is gone, and closes the popups of windows
3
- // that went. It runs while a delegation is running or a popup is open.
2
+ // declares gone those whose window is gone. It runs only while a delegation is
3
+ // running.
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { createPopups, type Popup, type Popups } from "./popups.ts";
7
6
  import type { Results } from "./results.ts";
8
7
  import type { TmuxClient, TmuxWindow } from "./tmux.ts";
9
8
  import { listWindows, windowState } from "./windows.ts";
@@ -24,27 +23,16 @@ function declareGone(pi: ExtensionAPI, results: Results, windows: TmuxWindow[] |
24
23
  }
25
24
  }
26
25
 
27
- /** One sweep: claim waiting replies, declare gone every delegation whose window is missing or exited with still no result, and close the popups of gone windows. */
28
- async function sweep(
29
- pi: ExtensionAPI,
30
- results: Results,
31
- tmux: TmuxClient,
32
- popups: Popups,
33
- live: () => boolean,
34
- ): Promise<void> {
35
- let windows: TmuxWindow[] | undefined;
36
- let asked = false;
26
+ /** One sweep: claim waiting replies, declare gone every delegation whose window is missing or exited with still no result. */
27
+ async function sweep(pi: ExtensionAPI, results: Results, tmux: TmuxClient, live: () => boolean): Promise<void> {
37
28
  if (results.running().length > 0) {
38
29
  const scan: { scanned?: boolean } = {};
39
30
  pi.events.emit(SCAN, scan);
40
31
  // No message:* provider: a waiting reply cannot be ruled out, so nothing is declared gone.
41
32
  if (scan.scanned === true) {
42
- windows = await listWindows(tmux); // one ask per tick
43
- asked = true;
44
- declareGone(pi, results, windows, live);
33
+ declareGone(pi, results, await listWindows(tmux), live); // one ask per tick
45
34
  }
46
35
  }
47
- if (popups.count() > 0) await popups.closeGone(asked ? windows : await listWindows(tmux));
48
36
  }
49
37
 
50
38
  /** The poll over the records and tmux. `arm` readies it for a new session; `stop` ends it for good. */
@@ -52,13 +40,12 @@ export function createPoll(
52
40
  pi: ExtensionAPI,
53
41
  results: Results,
54
42
  tmux: TmuxClient,
55
- ): { start(): void; stop(): void; arm(): void; track(popup: Popup): () => void } {
56
- /** The poll timer; set only while something is running or a popup is open. */
43
+ ): { start(): void; stop(): void; arm(): void } {
44
+ /** The poll timer; set only while a delegation is running. */
57
45
  let poll: ReturnType<typeof setInterval> | undefined;
58
46
  let ticking = false;
59
47
  /** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
60
48
  let live = true;
61
- const popups = createPopups(tmux);
62
49
 
63
50
  function stopPolling(): void {
64
51
  if (poll !== undefined) clearInterval(poll);
@@ -66,21 +53,21 @@ export function createPoll(
66
53
  }
67
54
 
68
55
  /** Whether the poll has anything left to watch. */
69
- const busy = (): boolean => results.running().length > 0 || popups.count() > 0;
56
+ const busy = (): boolean => results.running().length > 0;
70
57
 
71
58
  async function tick(): Promise<void> {
72
59
  if (ticking) return;
73
60
  ticking = true;
74
61
  try {
75
62
  if (!busy()) return stopPolling();
76
- await sweep(pi, results, tmux, popups, () => live);
63
+ await sweep(pi, results, tmux, () => live);
77
64
  if (!busy()) stopPolling();
78
65
  } finally {
79
66
  ticking = false;
80
67
  }
81
68
  }
82
69
 
83
- /** Start the timer when something is running or a popup is open; it never keeps the process alive. */
70
+ /** Start the timer while a delegation is running; it never keeps the process alive. */
84
71
  function startPolling(): void {
85
72
  if (poll !== undefined || !busy()) return;
86
73
  poll = setInterval(() => void tick(), POLL_MS);
@@ -89,22 +76,12 @@ export function createPoll(
89
76
 
90
77
  return {
91
78
  start: startPolling,
92
- track(popup: Popup): () => void {
93
- const forget = popups.track(popup);
94
- startPolling();
95
- return (): void => {
96
- forget();
97
- if (!busy()) stopPolling();
98
- };
99
- },
100
79
  stop(): void {
101
80
  live = false;
102
81
  stopPolling();
103
- popups.clear();
104
82
  },
105
83
  arm(): void {
106
84
  stopPolling();
107
- popups.clear();
108
85
  live = true;
109
86
  },
110
87
  };
package/src/tmux.ts CHANGED
@@ -1,6 +1,6 @@
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, spawn } from "node:child_process";
3
+ import { execFile } from "node:child_process";
4
4
 
5
5
  import type { WindowPlacement } from "./window-placement.ts";
6
6
 
@@ -18,7 +18,7 @@ export type TmuxWindow = { id: string; session: string; name: string; exited: bo
18
18
  /** A window just opened: its id and the name of the session it is in, when known. */
19
19
  export type OpenedWindow = { id: string; session: string | undefined };
20
20
 
21
- /** The oldest tmux delegate windows need: `new-session -e` and `display-popup` arrived in 3.2. */
21
+ /** The oldest tmux delegate windows need: `new-session -e` arrived in 3.2. */
22
22
  export const MIN_TMUX = { major: 3, minor: 2 } as const;
23
23
 
24
24
  export type TmuxVersion = { text: string; major: number; minor: number };
@@ -62,9 +62,6 @@ export class WindowOpenError extends Error {
62
62
  }
63
63
  }
64
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
65
  /** The session the parent's pane is in: its id (`$N`) and name. */
69
66
  export type OwnSession = { id: string; name: string };
70
67
 
@@ -89,16 +86,6 @@ export interface TmuxClient {
89
86
  clientFor(session: string): Promise<string | undefined>;
90
87
  /** Show the window to a client: `switch-client -c <client> -t @N`. */
91
88
  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
89
  /** The window's visible pane as text (`capture-pane -p`). */
103
90
  capture(windowId: string): Promise<string>;
104
91
  }
@@ -139,48 +126,6 @@ async function parentSession(run: Run): Promise<Parent | undefined> {
139
126
  return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t"), companion: marker === "1" };
140
127
  }
141
128
 
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
129
  const envArgs = (spec: WindowSpec): string[] =>
185
130
  Object.entries(spec.env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
186
131
 
@@ -250,10 +195,8 @@ function openCurrent(run: Run, spec: WindowSpec, parent: Parent | undefined): Pr
250
195
  return create(run, args);
251
196
  }
252
197
 
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"> {
198
+ /** The methods that reach a delegate's window from the user's terminal: clients, switch, capture. */
199
+ function navigation(run: Run): Pick<TmuxClient, "ownSession" | "clientFor" | "switchTo" | "capture"> {
257
200
  return {
258
201
  async ownSession() {
259
202
  const parent = await parentSession(run);
@@ -276,22 +219,6 @@ function navigation(
276
219
  await run(["switch-client", "-c", client, "-t", windowId]);
277
220
  },
278
221
 
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
222
  async capture(windowId) {
296
223
  return run(["capture-pane", "-p", "-t", windowId]);
297
224
  },
package/src/tracking.ts CHANGED
@@ -9,7 +9,6 @@ import { type Static, Type } from "typebox";
9
9
 
10
10
  import { type Mark, readMarks } from "./limits.ts";
11
11
  import { createPoll } from "./poll.ts";
12
- import type { Popup } from "./popups.ts";
13
12
  import { attemptLines, cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
14
13
  import {
15
14
  type DelegationState,
@@ -37,10 +36,8 @@ export { listWindows, ownWindow, windowState };
37
36
  export type Tracking = {
38
37
  /** Register `delegation_status` and `delegation_close`. */
39
38
  registerTools(): void;
40
- /** Start the poll when something is running or a popup is open. */
39
+ /** Start the poll when a delegation is running. */
41
40
  startPolling(): void;
42
- /** Remember a popup the parent opened; the poll closes it once its window is gone. Returns the call that forgets it when its process ends. */
43
- trackPopup(popup: Popup): () => void;
44
41
  /** Close one delegation as `delegation_close` does; throws as the tool does. */
45
42
  close(id: string): Promise<CloseResult>;
46
43
  /** On `session_start`: rebuild the records and restart the poll. */
@@ -410,7 +407,6 @@ export function createTracking(
410
407
  pi.registerTool(closeTool(results, tmux) as never);
411
408
  },
412
409
  startPolling: poll.start,
413
- trackPopup: poll.track,
414
410
  close: (id: string): Promise<CloseResult> => closeDelegation(results, tmux, id),
415
411
  restore(ctx: ExtensionContext): void {
416
412
  poll.arm();
package/src/popups.ts DELETED
@@ -1,49 +0,0 @@
1
- // The popups the parent opened over the user's tmux clients. tmux closes a
2
- // popup's contents when the delegate's window goes, but not the popup itself:
3
- // the poll closes it, through this registry, once its window is gone. A client
4
- // shows one popup at a time, so the registry holds the current popup per
5
- // client; a new popup replaces the old record, and a record is dropped when
6
- // its own popup's process ends, so a stale record never closes a newer popup.
7
- import type { TmuxClient, TmuxWindow } from "./tmux.ts";
8
-
9
- /** A popup the parent opened: the window it shows and the client it is on. */
10
- export type Popup = { windowId: string; windowName: string; client: string };
11
-
12
- export type Popups = {
13
- /** Remember a popup as its client's current one; returns the call that forgets exactly this popup. */
14
- track(popup: Popup): () => void;
15
- /** How many popups are remembered. */
16
- count(): number;
17
- /** Forget every popup, closing none. */
18
- clear(): void;
19
- /** Close each current popup whose window is not in `windows`; a failed listing (undefined) keeps them all for the next tick. */
20
- closeGone(windows: TmuxWindow[] | undefined): Promise<void>;
21
- };
22
-
23
- export function createPopups(tmux: TmuxClient): Popups {
24
- const current = new Map<string, Popup>();
25
- return {
26
- track(popup: Popup): () => void {
27
- const entry = { ...popup };
28
- current.set(entry.client, entry);
29
- return (): void => {
30
- if (current.get(entry.client) === entry) current.delete(entry.client);
31
- };
32
- },
33
- count: (): number => current.size,
34
- clear: (): void => current.clear(),
35
- async closeGone(windows: TmuxWindow[] | undefined): Promise<void> {
36
- if (windows === undefined) return;
37
- for (const entry of [...current.values()]) {
38
- const here = windows.some((window) => window.id === entry.windowId && window.name === entry.windowName);
39
- if (here || current.get(entry.client) !== entry) continue;
40
- current.delete(entry.client); // forgotten first, so nothing closes it twice
41
- try {
42
- await tmux.closePopup(entry.client);
43
- } catch {
44
- // The popup may already be closed or its client gone; one try, then forgotten.
45
- }
46
- }
47
- },
48
- };
49
- }