pi-squire 0.12.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.
@@ -0,0 +1,438 @@
1
+ // The new-delegate form behind `/delegate` with no arguments: pick an agent,
2
+ // write the task, name it, then a settings screen for model, thinking,
3
+ // auto-exit, working directory and worktree. It collects choices and hands
4
+ // them to the same launch the command uses (`start` in `NewDelegateDeps`);
5
+ // it opens no window and sends no mail itself.
6
+ import {
7
+ clampThinkingLevel,
8
+ getSupportedThinkingLevels,
9
+ type Model,
10
+ type ModelThinkingLevel,
11
+ } from "@earendil-works/pi-ai";
12
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ type Component,
15
+ fuzzyFilter,
16
+ getKeybindings,
17
+ Input,
18
+ type SelectItem,
19
+ SelectList,
20
+ type SettingItem,
21
+ SettingsList,
22
+ type SettingsListTheme,
23
+ } from "@earendil-works/pi-tui";
24
+
25
+ import { type Agent, isThinking, type Roster, THINKING_LEVELS } from "./agents.ts";
26
+ import { resolveDirectory } from "./workspace.ts";
27
+
28
+ /** What the form asks `/delegate` to start; the fields after `task` are set only when the user changed them. */
29
+ export type NewRequest = {
30
+ agent: string;
31
+ task: string;
32
+ label?: string;
33
+ model?: string;
34
+ thinking?: string;
35
+ autoExit?: boolean;
36
+ cwd?: string;
37
+ worktree?: string;
38
+ };
39
+
40
+ /** What the settings screen returns: only the rows the user changed from the defaults. */
41
+ export type FormResult = Pick<NewRequest, "model" | "thinking" | "autoExit" | "cwd" | "worktree">;
42
+
43
+ /** What the form reads from the launch path in `index.ts`. */
44
+ export type NewDelegateDeps = {
45
+ /** Why a delegate cannot start now (not inside tmux), or undefined. */
46
+ unavailable(): string | undefined;
47
+ roster(ctx: ExtensionContext): Roster;
48
+ /** The refusal text for an empty roster. */
49
+ noAgents(): string;
50
+ /** Why `value` is not a usable label, or undefined; the check `--label` gets. */
51
+ labelProblem(value: string): string | undefined;
52
+ /** Start the delegate as `/delegate` would, notifying the start or the refusal. */
53
+ start(request: NewRequest, ctx: ExtensionContext): Promise<void>;
54
+ };
55
+
56
+ type Style = Pick<Theme, "fg">;
57
+
58
+ type SubmenuDone = (selectedValue?: string) => void;
59
+
60
+ /** The row ids, in the order shown. */
61
+ const START = "start";
62
+ const MODEL = "model";
63
+ const THINKING = "thinking";
64
+ const AUTO_EXIT = "auto-exit";
65
+ const CWD = "cwd";
66
+ const WORKTREE = "worktree";
67
+ const NONE = "(none)";
68
+
69
+ const ON = "on";
70
+ const OFF = "off";
71
+
72
+ /** Rows the settings list shows at once. */
73
+ const VISIBLE = 8;
74
+
75
+ /** One line of text input with a title, for the directory and the branch. */
76
+ class TextPrompt implements Component {
77
+ private readonly input = new Input();
78
+ private readonly title: string;
79
+ private readonly style: Style;
80
+
81
+ constructor(title: string, value: string, style: Style, done: (value?: string) => void) {
82
+ this.title = title;
83
+ this.style = style;
84
+ this.input.setValue(value);
85
+ this.input.handleInput("\x05"); // the cursor starts at the end of the value
86
+ this.input.focused = true;
87
+ this.input.onSubmit = (text: string): void => done(text.trim());
88
+ this.input.onEscape = (): void => done(undefined);
89
+ }
90
+
91
+ handleInput(data: string): void {
92
+ this.input.handleInput(data);
93
+ }
94
+
95
+ invalidate(): void {
96
+ this.input.invalidate();
97
+ }
98
+
99
+ render(width: number): string[] {
100
+ return [
101
+ this.style.fg("accent", this.title),
102
+ "",
103
+ ...this.input.render(width),
104
+ "",
105
+ this.style.fg("dim", " Enter to set · Esc to keep the current value"),
106
+ ];
107
+ }
108
+ }
109
+
110
+ /** A filter box over a list of models; typing narrows it. */
111
+ class ModelPicker implements Component {
112
+ private readonly input = new Input();
113
+ private readonly style: Style;
114
+ private readonly items: SelectItem[];
115
+ private readonly done: (value?: string) => void;
116
+ private list: SelectList;
117
+
118
+ constructor(items: SelectItem[], current: string, style: Style, done: (value?: string) => void) {
119
+ this.style = style;
120
+ this.items = items;
121
+ this.done = done;
122
+ this.input.focused = true;
123
+ this.list = this.listOf(items, current);
124
+ }
125
+
126
+ /** A list of `items` with `current` selected when it is among them. */
127
+ private listOf(items: SelectItem[], current: string | undefined): SelectList {
128
+ const list = new SelectList(items, 10, {
129
+ selectedPrefix: (text: string): string => this.style.fg("accent", text),
130
+ selectedText: (text: string): string => this.style.fg("accent", text),
131
+ description: (text: string): string => this.style.fg("muted", text),
132
+ scrollInfo: (text: string): string => this.style.fg("dim", text),
133
+ noMatch: (text: string): string => this.style.fg("warning", text),
134
+ });
135
+ const at = items.findIndex((item) => item.value === current);
136
+ if (at >= 0) list.setSelectedIndex(at);
137
+ list.onSelect = (item: SelectItem): void => this.done(item.value);
138
+ list.onCancel = (): void => this.done(undefined);
139
+ return list;
140
+ }
141
+
142
+ handleInput(data: string): void {
143
+ const keys = getKeybindings();
144
+ if (
145
+ keys.matches(data, "tui.select.up") ||
146
+ keys.matches(data, "tui.select.down") ||
147
+ keys.matches(data, "tui.select.confirm") ||
148
+ keys.matches(data, "tui.select.cancel")
149
+ ) {
150
+ this.list.handleInput(data);
151
+ return;
152
+ }
153
+ this.input.handleInput(data);
154
+ // Fuzzy over the whole id: `SelectList.setFilter` matches the start of the value only.
155
+ this.list = this.listOf(
156
+ fuzzyFilter(this.items, this.input.getValue(), (item) => item.label),
157
+ undefined,
158
+ );
159
+ }
160
+
161
+ invalidate(): void {
162
+ this.list.invalidate();
163
+ }
164
+
165
+ render(width: number): string[] {
166
+ return [
167
+ this.style.fg("accent", "Model"),
168
+ ...this.input.render(width),
169
+ "",
170
+ ...this.list.render(width),
171
+ this.style.fg("dim", " Type to filter · Enter to pick · Esc to keep the current model"),
172
+ ];
173
+ }
174
+ }
175
+
176
+ /** The settings screen's own choices; what the user sees and what Start returns. */
177
+ export type FormOptions = {
178
+ agent: Agent;
179
+ /** The registry's available models; the agent's default is selected even when it is not among them. */
180
+ models: readonly Model<never>[];
181
+ /** The parent's working directory, which the directory row starts at. */
182
+ cwd: string;
183
+ style: Style;
184
+ /** Why `value` is not a usable working directory, or undefined; the check `--cwd` gets. */
185
+ cwdProblem(value: string): string | undefined;
186
+ done(result: FormResult | undefined): void;
187
+ };
188
+
189
+ /**
190
+ * The settings screen: Start, then a row for each choice, all at the agent's
191
+ * defaults. Start returns the rows the user changed; the rest stay unset, so a
192
+ * form start and a typed `/delegate` with those flags are the same request.
193
+ */
194
+ export class SettingsForm implements Component {
195
+ private readonly list: SettingsList;
196
+ private readonly items: SettingItem[];
197
+ private readonly byId = new Map<string, Model<never>>();
198
+ private model: string;
199
+ private thinking: string;
200
+ private autoExit: boolean;
201
+ private cwd: string;
202
+ private worktree = "";
203
+ private status = "";
204
+ private readonly options: FormOptions;
205
+
206
+ constructor(options: FormOptions) {
207
+ this.options = options;
208
+ const { agent, models, style } = options;
209
+ for (const model of models) this.byId.set(`${model.provider}/${model.id}`, model);
210
+ this.model = agent.model;
211
+ this.thinking = agent.thinking;
212
+ this.autoExit = agent.autoExit;
213
+ this.cwd = options.cwd;
214
+ this.clampInitialThinking();
215
+ const modelItems: SelectItem[] = [...new Set([agent.model, ...this.byId.keys()])].map((value) => ({
216
+ value,
217
+ label: value,
218
+ }));
219
+ this.items = [
220
+ { id: START, label: "Start", currentValue: "▶ launch", values: ["▶ launch"] },
221
+ {
222
+ id: MODEL,
223
+ label: "Model",
224
+ currentValue: this.model,
225
+ description: "Filterable list of the available models.",
226
+ submenu: (current: string, done: SubmenuDone): Component => new ModelPicker(modelItems, current, style, done),
227
+ },
228
+ {
229
+ id: THINKING,
230
+ label: "Thinking",
231
+ currentValue: this.thinking,
232
+ values: this.levels(),
233
+ description: "Only the levels the selected model supports.",
234
+ },
235
+ {
236
+ id: AUTO_EXIT,
237
+ label: "Auto-exit",
238
+ currentValue: this.autoExit ? ON : OFF,
239
+ values: [ON, OFF],
240
+ description: "Close the window once the delegate finishes.",
241
+ },
242
+ {
243
+ id: CWD,
244
+ label: "Working directory",
245
+ currentValue: this.cwd,
246
+ description: "An existing directory. Not with a worktree.",
247
+ submenu: (current: string, done: SubmenuDone): Component =>
248
+ new TextPrompt("Working directory", current, style, done),
249
+ },
250
+ {
251
+ id: WORKTREE,
252
+ label: "Worktree",
253
+ currentValue: NONE,
254
+ description: "A new branch name for a managed worktree; empty for none. Not with another working directory.",
255
+ submenu: (current: string, done: SubmenuDone): Component =>
256
+ new TextPrompt("Worktree branch (empty for none)", current === NONE ? "" : current, style, (value) => {
257
+ if (value === undefined) done(undefined);
258
+ else done(value === "" ? NONE : value);
259
+ }),
260
+ },
261
+ ];
262
+ this.list = new SettingsList(
263
+ this.items,
264
+ VISIBLE,
265
+ settingsTheme(style),
266
+ (id: string, value: string): void => this.changed(id, value),
267
+ (): void => options.done(undefined),
268
+ );
269
+ }
270
+
271
+ /** The agent's default level, when its default model does not support it: clamped, visibly, and so submitted as an override. */
272
+ private clampInitialThinking(): void {
273
+ const model = this.byId.get(this.model);
274
+ if (model === undefined || !isThinking(this.thinking) || this.levels().includes(this.thinking)) return;
275
+ const clamped = clampThinkingLevel(model, this.thinking as ModelThinkingLevel);
276
+ this.status = `thinking ${this.thinking} is not supported by ${this.model}; now ${clamped}`;
277
+ this.thinking = clamped;
278
+ }
279
+
280
+ /** The levels the selected model supports; every level when the model is not in the registry. */
281
+ levels(): string[] {
282
+ const model = this.byId.get(this.model);
283
+ return model === undefined ? [...THINKING_LEVELS] : getSupportedThinkingLevels(model);
284
+ }
285
+
286
+ private row(id: string): SettingItem {
287
+ const item = this.items.find((candidate) => candidate.id === id);
288
+ if (item === undefined) throw new Error(`no row ${id}`);
289
+ return item;
290
+ }
291
+
292
+ private set(id: string, value: string): void {
293
+ this.row(id).currentValue = value;
294
+ this.list.updateValue(id, value);
295
+ }
296
+
297
+ /** Refuse a change: put the row back and say why. */
298
+ private refuse(id: string, value: string, why: string): void {
299
+ this.set(id, value);
300
+ this.status = why;
301
+ }
302
+
303
+ private changed(id: string, value: string): void {
304
+ this.status = "";
305
+ if (id === START) this.submit();
306
+ else if (id === MODEL) this.pickModel(value);
307
+ else if (id === THINKING) this.thinking = value;
308
+ else if (id === AUTO_EXIT) this.autoExit = value === ON;
309
+ else if (id === CWD) this.pickCwd(value);
310
+ else if (id === WORKTREE) this.pickWorktree(value === NONE ? "" : value);
311
+ }
312
+
313
+ /** A new model narrows the thinking levels; a level it does not support is clamped, and the screen says so. */
314
+ private pickModel(value: string): void {
315
+ this.model = value;
316
+ const model = this.byId.get(value);
317
+ const levels = this.levels();
318
+ this.row(THINKING).values = levels;
319
+ if (model !== undefined && isThinking(this.thinking) && !levels.includes(this.thinking)) {
320
+ const clamped = clampThinkingLevel(model, this.thinking as ModelThinkingLevel);
321
+ this.status = `thinking ${this.thinking} is not supported by ${value}; now ${clamped}`;
322
+ this.thinking = clamped;
323
+ this.set(THINKING, clamped);
324
+ }
325
+ }
326
+
327
+ private pickCwd(value: string): void {
328
+ if (value !== this.options.cwd && this.worktree !== "") {
329
+ this.refuse(CWD, this.cwd, "a working directory and a worktree are mutually exclusive; clear the worktree first");
330
+ return;
331
+ }
332
+ const problem = value === this.options.cwd ? undefined : this.options.cwdProblem(value);
333
+ if (problem !== undefined) {
334
+ this.refuse(CWD, this.cwd, problem);
335
+ return;
336
+ }
337
+ this.cwd = value;
338
+ }
339
+
340
+ private pickWorktree(value: string): void {
341
+ if (value !== "" && this.cwd !== this.options.cwd) {
342
+ this.refuse(
343
+ WORKTREE,
344
+ this.worktree === "" ? NONE : this.worktree,
345
+ "a worktree and a changed working directory are mutually exclusive; reset the working directory first",
346
+ );
347
+ return;
348
+ }
349
+ this.worktree = value;
350
+ }
351
+
352
+ private submit(): void {
353
+ if (this.worktree !== "" && this.cwd !== this.options.cwd) {
354
+ this.status = "a working directory and a worktree are mutually exclusive; name only one";
355
+ return;
356
+ }
357
+ const problem = this.cwd === this.options.cwd ? undefined : this.options.cwdProblem(this.cwd);
358
+ if (problem !== undefined) {
359
+ this.status = problem;
360
+ return;
361
+ }
362
+ this.options.done({
363
+ ...(this.model === this.options.agent.model ? {} : { model: this.model }),
364
+ ...(this.thinking === this.options.agent.thinking ? {} : { thinking: this.thinking }),
365
+ ...(this.autoExit === this.options.agent.autoExit ? {} : { autoExit: this.autoExit }),
366
+ ...(this.cwd === this.options.cwd ? {} : { cwd: this.cwd }),
367
+ ...(this.worktree === "" ? {} : { worktree: this.worktree }),
368
+ });
369
+ }
370
+
371
+ handleInput(data: string): void {
372
+ this.list.handleInput(data);
373
+ }
374
+
375
+ invalidate(): void {
376
+ this.list.invalidate();
377
+ }
378
+
379
+ render(width: number): string[] {
380
+ const lines = this.list.render(width);
381
+ return this.status === "" ? lines : [...lines, "", this.options.style.fg("warning", ` ${this.status}`)];
382
+ }
383
+ }
384
+
385
+ /** The list's colours, from the session's theme. */
386
+ function settingsTheme(style: Style): SettingsListTheme {
387
+ return {
388
+ label: (text: string, selected: boolean): string => (selected ? style.fg("accent", text) : text),
389
+ value: (text: string, selected: boolean): string => (selected ? style.fg("accent", text) : style.fg("muted", text)),
390
+ description: (text: string): string => style.fg("dim", text),
391
+ cursor: style.fg("accent", "→ "),
392
+ hint: (text: string): string => style.fg("dim", text),
393
+ };
394
+ }
395
+
396
+ /** `value` as a problem message, or undefined when it names a usable existing directory (the `--cwd` check). */
397
+ function cwdProblem(value: string, base: string): string | undefined {
398
+ try {
399
+ resolveDirectory(value, base);
400
+ return undefined;
401
+ } catch (err) {
402
+ return (err as Error).message;
403
+ }
404
+ }
405
+
406
+ /** Run the form for `ctx` and start the delegate it describes; a cancelled step ends it quietly. */
407
+ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateDeps): Promise<void> {
408
+ const fail = (message: string): void => ctx.ui.notify(`delegate: ${message}`, "error");
409
+ const unavailable = deps.unavailable();
410
+ if (unavailable !== undefined) return fail(unavailable);
411
+ const roster = deps.roster(ctx);
412
+ if (roster.agents.length === 0) return fail(deps.noAgents());
413
+ const options = roster.agents.map((agent) => `${agent.name} — ${agent.description}`);
414
+ const picked = await ctx.ui.select("New delegate: agent", options);
415
+ const agent = roster.agents[options.indexOf(picked ?? "")];
416
+ if (agent === undefined) return;
417
+ const task = await ctx.ui.editor(`Task for ${agent.name}`);
418
+ if (task === undefined) return;
419
+ if (task.trim() === "") return fail("a task is required");
420
+ const typed = await ctx.ui.input(`Label (optional; empty for ${agent.name}-<id>)`, "letters, digits, _ or -");
421
+ if (typed === undefined) return;
422
+ const label = typed.trim() === "" ? undefined : typed.trim();
423
+ const badLabel = label === undefined ? undefined : deps.labelProblem(label);
424
+ if (badLabel !== undefined) return fail(badLabel);
425
+ const chosen = await ctx.ui.custom<FormResult | undefined>(
426
+ (_tui, theme, _keys, done): Component =>
427
+ new SettingsForm({
428
+ agent,
429
+ models: ctx.modelRegistry.getAvailable() as Model<never>[],
430
+ cwd: ctx.cwd,
431
+ style: theme,
432
+ cwdProblem: (value: string): string | undefined => cwdProblem(value, ctx.cwd),
433
+ done,
434
+ }),
435
+ );
436
+ if (chosen === undefined) return;
437
+ await deps.start({ agent: agent.name, task, ...(label === undefined ? {} : { label }), ...chosen }, ctx);
438
+ }
package/src/poll.ts ADDED
@@ -0,0 +1,111 @@
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.
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { createPopups, type Popup, type Popups } from "./popups.ts";
7
+ import type { Results } from "./results.ts";
8
+ import type { TmuxClient, TmuxWindow } from "./tmux.ts";
9
+ import { listWindows, windowState } from "./windows.ts";
10
+
11
+ /** The hook a tick emits so the provider claims waiting replies before windows are checked. */
12
+ const SCAN = "message:scan";
13
+ /** How often the parent checks its running delegations. */
14
+ const POLL_MS = 5000;
15
+
16
+ /** Record gone each running delegation whose window is missing or exited; a failed list declares nothing. */
17
+ function declareGone(pi: ExtensionAPI, results: Results, windows: TmuxWindow[] | undefined, live: () => boolean): void {
18
+ if (windows === undefined) return;
19
+ for (const record of results.running()) {
20
+ if (windowState(windows, record) !== "gone" || !live()) continue;
21
+ // A reply may have landed while the windows before this one were checked.
22
+ pi.events.emit(SCAN, {});
23
+ results.recordGone(record.id);
24
+ }
25
+ }
26
+
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;
37
+ if (results.running().length > 0) {
38
+ const scan: { scanned?: boolean } = {};
39
+ pi.events.emit(SCAN, scan);
40
+ // No message:* provider: a waiting reply cannot be ruled out, so nothing is declared gone.
41
+ if (scan.scanned === true) {
42
+ windows = await listWindows(tmux); // one ask per tick
43
+ asked = true;
44
+ declareGone(pi, results, windows, live);
45
+ }
46
+ }
47
+ if (popups.count() > 0) await popups.closeGone(asked ? windows : await listWindows(tmux));
48
+ }
49
+
50
+ /** The poll over the records and tmux. `arm` readies it for a new session; `stop` ends it for good. */
51
+ export function createPoll(
52
+ pi: ExtensionAPI,
53
+ results: Results,
54
+ 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. */
57
+ let poll: ReturnType<typeof setInterval> | undefined;
58
+ let ticking = false;
59
+ /** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
60
+ let live = true;
61
+ const popups = createPopups(tmux);
62
+
63
+ function stopPolling(): void {
64
+ if (poll !== undefined) clearInterval(poll);
65
+ poll = undefined;
66
+ }
67
+
68
+ /** Whether the poll has anything left to watch. */
69
+ const busy = (): boolean => results.running().length > 0 || popups.count() > 0;
70
+
71
+ async function tick(): Promise<void> {
72
+ if (ticking) return;
73
+ ticking = true;
74
+ try {
75
+ if (!busy()) return stopPolling();
76
+ await sweep(pi, results, tmux, popups, () => live);
77
+ if (!busy()) stopPolling();
78
+ } finally {
79
+ ticking = false;
80
+ }
81
+ }
82
+
83
+ /** Start the timer when something is running or a popup is open; it never keeps the process alive. */
84
+ function startPolling(): void {
85
+ if (poll !== undefined || !busy()) return;
86
+ poll = setInterval(() => void tick(), POLL_MS);
87
+ poll.unref?.();
88
+ }
89
+
90
+ return {
91
+ 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
+ stop(): void {
101
+ live = false;
102
+ stopPolling();
103
+ popups.clear();
104
+ },
105
+ arm(): void {
106
+ stopPolling();
107
+ popups.clear();
108
+ live = true;
109
+ },
110
+ };
111
+ }
package/src/popups.ts ADDED
@@ -0,0 +1,49 @@
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
+ }