pi-squire 0.13.1 → 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 +26 -1
- package/README.md +13 -17
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/navigator.ts +27 -51
- package/src/poll.ts +10 -33
- package/src/tmux.ts +4 -88
- package/src/tracking.ts +1 -5
- package/src/popups.ts +0 -49
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ 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
|
+
|
|
9
33
|
## [0.13.1] - 2026-10-27
|
|
10
34
|
|
|
11
35
|
### Changed
|
|
@@ -520,7 +544,8 @@ author's dotfiles; the changes below are against that copy.
|
|
|
520
544
|
|
|
521
545
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
522
546
|
|
|
523
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.
|
|
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
|
|
524
549
|
[0.13.1]: https://github.com/gvanderclay/pi-squire/compare/v0.13.0...v0.13.1
|
|
525
550
|
[0.13.0]: https://github.com/gvanderclay/pi-squire/compare/v0.12.0...v0.13.0
|
|
526
551
|
[0.12.0]: https://github.com/gvanderclay/pi-squire/compare/v0.11.0...v0.12.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
|
|
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,25 +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` |
|
|
192
|
-
| `p` | peek: a snapshot of the window's pane (`capture-pane`) in an overlay; `r` refreshes, `enter`
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
detaching in it (`prefix d`) closes it; its title names that key with your
|
|
201
|
-
prefix. When the delegate's window goes, the parent
|
|
202
|
-
closes the popup within one poll (up to 5 s). No tmux key bindings or options are
|
|
203
|
-
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.
|
|
204
199
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
210
206
|
|
|
211
207
|
## Working directory
|
|
212
208
|
|
|
@@ -1049,7 +1045,7 @@ Other extensions may rely on:
|
|
|
1049
1045
|
no-argument forms: `/delegate` opens the new-delegate form and
|
|
1050
1046
|
`/delegate-resume` the resume picker;
|
|
1051
1047
|
- the `/delegates` command and its `alt+shift+d` shortcut, and `/delegate-open <id>` with its
|
|
1052
|
-
`--
|
|
1048
|
+
`--switch` and `--peek` flags;
|
|
1053
1049
|
- the `[delegate]` result text and the closed-without-a-result text;
|
|
1054
1050
|
- `AGENT.md`'s frontmatter keys and its location,
|
|
1055
1051
|
`<agent dir>/squire/agents/<name>/AGENT.md`;
|
package/package.json
CHANGED
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:
|
|
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
|
|
3
|
-
// the user's tmux client
|
|
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
|
|
14
|
-
export type OpenMode = "
|
|
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> [--
|
|
17
|
-
const OPEN_FLAGS = ["--
|
|
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: "
|
|
28
|
+
type PanelResult = { action: "switch" | "close" | "peek"; id: string } | { action: "new" } | undefined;
|
|
30
29
|
|
|
31
|
-
/** What the peek hands back:
|
|
32
|
-
type PeekResult = { action: "
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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: "
|
|
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 --
|
|
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 ? "
|
|
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, "
|
|
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(
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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 === "
|
|
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
|
|
315
|
+
await switchTo(env, ctx, item);
|
|
339
316
|
return false;
|
|
340
317
|
}
|
|
341
|
-
await
|
|
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
|
|
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, "
|
|
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
|
|
3
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
56
|
-
/** The poll timer; set only while
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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`
|
|
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,61 +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
|
-
/**
|
|
143
|
-
* The key that starts a tmux command, as tmux names it (`C-b`), for the
|
|
144
|
-
* popup's title; `prefix` when it cannot be read or is unset.
|
|
145
|
-
*/
|
|
146
|
-
async function prefixKey(run: Run): Promise<string> {
|
|
147
|
-
try {
|
|
148
|
-
const key = (await run(["show-options", "-gv", "prefix"])).trim();
|
|
149
|
-
return key === "" || key === "None" ? "prefix" : key;
|
|
150
|
-
} catch {
|
|
151
|
-
return "prefix";
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** How long a launched popup may take to fail before it counts as shown. */
|
|
156
|
-
const POPUP_SETTLE_MS = 300;
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Launch `display-popup`, which blocks until the popup closes, detached and
|
|
160
|
-
* outside `runTmux`'s timeout. Resolves once it is up; rejects on a spawn
|
|
161
|
-
* error or an exit within the settle time. Its stderr is read only until then
|
|
162
|
-
* and then released, so the open popup keeps nothing of this process alive;
|
|
163
|
-
* `onExit` runs when the popup's process ends, however it was closed.
|
|
164
|
-
*/
|
|
165
|
-
function launchPopup(args: string[], onExit: () => void): Promise<void> {
|
|
166
|
-
return new Promise((resolve, reject) => {
|
|
167
|
-
let settled = false;
|
|
168
|
-
let stderr = "";
|
|
169
|
-
const child = spawn("tmux", args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
170
|
-
const onData = (chunk: Buffer): void => {
|
|
171
|
-
if (stderr.length < 4096) stderr += chunk.toString();
|
|
172
|
-
};
|
|
173
|
-
child.stderr?.on("data", onData);
|
|
174
|
-
const settle = (error?: Error): void => {
|
|
175
|
-
if (settled) return;
|
|
176
|
-
settled = true;
|
|
177
|
-
clearTimeout(timer);
|
|
178
|
-
child.stderr?.off("data", onData);
|
|
179
|
-
child.stderr?.destroy();
|
|
180
|
-
if (error === undefined) resolve();
|
|
181
|
-
else reject(error);
|
|
182
|
-
};
|
|
183
|
-
const timer = setTimeout(() => settle(), POPUP_SETTLE_MS);
|
|
184
|
-
child.on("error", (err) => settle(err));
|
|
185
|
-
child.on("close", (code) => {
|
|
186
|
-
if (!settled && code !== 0) {
|
|
187
|
-
settle(new Error(stderr.trim() !== "" ? stderr.trim() : `tmux display-popup exited with ${code}`));
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
settle();
|
|
191
|
-
onExit();
|
|
192
|
-
});
|
|
193
|
-
child.unref();
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
129
|
const envArgs = (spec: WindowSpec): string[] =>
|
|
198
130
|
Object.entries(spec.env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
199
131
|
|
|
@@ -263,10 +195,8 @@ function openCurrent(run: Run, spec: WindowSpec, parent: Parent | undefined): Pr
|
|
|
263
195
|
return create(run, args);
|
|
264
196
|
}
|
|
265
197
|
|
|
266
|
-
/** The methods that reach a delegate's window from the user's terminal: clients, switch,
|
|
267
|
-
function navigation(
|
|
268
|
-
run: Run,
|
|
269
|
-
): 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"> {
|
|
270
200
|
return {
|
|
271
201
|
async ownSession() {
|
|
272
202
|
const parent = await parentSession(run);
|
|
@@ -289,20 +219,6 @@ function navigation(
|
|
|
289
219
|
await run(["switch-client", "-c", client, "-t", windowId]);
|
|
290
220
|
},
|
|
291
221
|
|
|
292
|
-
async popup(client, window, onExit) {
|
|
293
|
-
if (!/^@[0-9]+$/.test(window.id)) throw new Error(`${window.id} is not a tmux window id`);
|
|
294
|
-
const sessionId = (await run(["display-message", "-p", "-t", window.id, "-F", "#{session_id}"])).trim();
|
|
295
|
-
if (!/^\$[0-9]+$/.test(sessionId)) throw new Error(`tmux reported an invalid session id for ${window.id}`);
|
|
296
|
-
// Ids are `@N` and `$N` only; quoted so the popup's shell does not expand `$N`.
|
|
297
|
-
const command = `tmux select-window -t '${window.id}' \\; attach-session -t '${sessionId}'`;
|
|
298
|
-
const title = `${window.name} · ${await prefixKey(run)} d to close`;
|
|
299
|
-
await launchPopup(["display-popup", "-c", client, "-E", "-w", "90%", "-h", "90%", "-T", title, command], onExit);
|
|
300
|
-
},
|
|
301
|
-
|
|
302
|
-
async closePopup(client) {
|
|
303
|
-
await run(["display-popup", "-C", "-c", client]);
|
|
304
|
-
},
|
|
305
|
-
|
|
306
222
|
async capture(windowId) {
|
|
307
223
|
return run(["capture-pane", "-p", "-t", windowId]);
|
|
308
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
|
|
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
|
-
}
|