pi-squire 0.12.0 → 0.13.1

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,382 @@
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
5
+ // moves the user's terminal.
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { type Component, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
8
+
9
+ import { type DelegationState, deriveState, isStarted, type Results, recordedName } from "./results.ts";
10
+ import type { TmuxClient, TmuxWindow } from "./tmux.ts";
11
+ import { listWindows, ownWindow, type Tracking } from "./tracking.ts";
12
+
13
+ /** What `/delegate-open` does: `auto` is what enter does in the panel. */
14
+ export type OpenMode = "auto" | "popup" | "switch" | "peek";
15
+
16
+ export const OPEN_USAGE = "/delegate-open <id> [--popup|--switch|--peek]";
17
+ const OPEN_FLAGS = ["--popup", "--switch", "--peek"] as const;
18
+
19
+ /** One delegation whose window is open. */
20
+ type OpenDelegate = {
21
+ id: string;
22
+ name: string;
23
+ agent: string;
24
+ state: DelegationState;
25
+ window: TmuxWindow;
26
+ };
27
+
28
+ /** 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;
30
+
31
+ /** What the peek hands back: open the delegate, or nothing. */
32
+ type PeekResult = { action: "open" } | undefined;
33
+
34
+ const HINT = "↑↓ select · enter open · p peek · s switch · x close · n new · esc leave";
35
+ const EMPTY = "No open delegates · n new · esc close";
36
+ const PEEK_HINT = "r refresh · enter open · esc back";
37
+
38
+ /** Where a peek shows: over the panel or the editor, not in its place. */
39
+ const PEEK_OPTIONS = { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%", anchor: "center" } } as const;
40
+
41
+ type PanelTheme = { fg(color: string, text: string): string; bold(text: string): string };
42
+
43
+ type Capture = { text: string; error: string | undefined; loading: boolean };
44
+
45
+ /** The lines a peek shows: the captured pane's last rows, or why there is none. */
46
+ function peekBody(peek: Capture, rows: number): string[] {
47
+ if (peek.loading) return ["Capturing…"];
48
+ if (peek.error !== undefined) return [`Could not capture the pane: ${peek.error}`];
49
+ return peek.text
50
+ .replace(/\s+$/, "")
51
+ .split("\n")
52
+ .slice(-Math.max(5, rows - 8));
53
+ }
54
+
55
+ /** The panel's list of open delegates, as a pi-tui component. */
56
+ class Panel implements Component {
57
+ private selected = 0;
58
+ private readonly items: readonly OpenDelegate[];
59
+ private readonly theme: PanelTheme;
60
+ private readonly done: (result: PanelResult) => void;
61
+ private readonly redraw: () => void;
62
+
63
+ constructor(parts: {
64
+ items: readonly OpenDelegate[];
65
+ theme: PanelTheme;
66
+ done: (result: PanelResult) => void;
67
+ redraw: () => void;
68
+ }) {
69
+ this.items = parts.items;
70
+ this.theme = parts.theme;
71
+ this.done = parts.done;
72
+ this.redraw = parts.redraw;
73
+ }
74
+
75
+ invalidate(): void {}
76
+
77
+ handleInput(data: string): void {
78
+ const item = this.items[this.selected];
79
+ const count = this.items.length;
80
+ if (matchesKey(data, "escape")) this.done(undefined);
81
+ else if (matchesKey(data, "n")) this.done({ action: "new" });
82
+ else if (item === undefined) return;
83
+ else if (matchesKey(data, "up")) this.selected = (this.selected + count - 1) % count;
84
+ else if (matchesKey(data, "down")) this.selected = (this.selected + 1) % count;
85
+ else if (matchesKey(data, "enter")) this.done({ action: "open", id: item.id });
86
+ 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
+ else if (matchesKey(data, "x")) this.done({ action: "close", id: item.id });
89
+ this.redraw();
90
+ }
91
+
92
+ render(width: number): string[] {
93
+ const fit = (text: string): string => truncateToWidth(text, Math.max(1, width));
94
+ if (this.items.length === 0) return [this.theme.bold("Delegates"), "", EMPTY].map(fit);
95
+ const lines = this.items.map((item, index) => {
96
+ const where = item.window.session === "" ? item.window.id : `${item.window.id} in ${item.window.session}`;
97
+ const row = `${item.name} · ${item.agent} · ${item.state} · ${where}`;
98
+ return index === this.selected ? this.theme.fg("accent", `▸ ${row}`) : ` ${row}`;
99
+ });
100
+ return [this.theme.bold("Delegates"), "", ...lines, "", this.theme.fg("dim", HINT)].map(fit);
101
+ }
102
+ }
103
+
104
+ /** A snapshot of one delegate's pane, shown as an overlay: `r` refreshes, enter opens, esc returns. */
105
+ class PeekView implements Component {
106
+ private capture: Capture = { text: "", error: undefined, loading: true };
107
+ private readonly item: OpenDelegate;
108
+ private readonly theme: PanelTheme;
109
+ private readonly read: (windowId: string) => Promise<string>;
110
+ private readonly done: (result: PeekResult) => void;
111
+ private readonly redraw: () => void;
112
+ private readonly rows: () => number;
113
+
114
+ constructor(parts: {
115
+ item: OpenDelegate;
116
+ theme: PanelTheme;
117
+ read: (windowId: string) => Promise<string>;
118
+ done: (result: PeekResult) => void;
119
+ redraw: () => void;
120
+ rows: () => number;
121
+ }) {
122
+ this.item = parts.item;
123
+ this.theme = parts.theme;
124
+ this.read = parts.read;
125
+ this.done = parts.done;
126
+ this.redraw = parts.redraw;
127
+ this.rows = parts.rows;
128
+ this.refresh();
129
+ }
130
+
131
+ invalidate(): void {}
132
+
133
+ private refresh(): void {
134
+ this.capture = { text: "", error: undefined, loading: true };
135
+ void this.read(this.item.window.id).then(
136
+ (text) => {
137
+ this.capture = { text, error: undefined, loading: false };
138
+ this.redraw();
139
+ },
140
+ (err: unknown) => {
141
+ this.capture = { text: "", error: (err as Error).message, loading: false };
142
+ this.redraw();
143
+ },
144
+ );
145
+ }
146
+
147
+ handleInput(data: string): void {
148
+ if (matchesKey(data, "escape")) this.done(undefined);
149
+ else if (matchesKey(data, "r")) this.refresh();
150
+ else if (matchesKey(data, "enter")) this.done({ action: "open" });
151
+ this.redraw();
152
+ }
153
+
154
+ render(width: number): string[] {
155
+ const fit = (text: string): string => truncateToWidth(text, Math.max(1, width));
156
+ const head = this.theme.bold(`Peek: ${this.item.name} (${this.item.window.id})`);
157
+ const body = peekBody(this.capture, this.rows());
158
+ return [head, "", ...body, "", this.theme.fg("dim", PEEK_HINT)].map(fit);
159
+ }
160
+ }
161
+
162
+ /** What `src/index.ts` needs from the navigator. */
163
+ export type Navigator = {
164
+ /** The panel, looping back after a close. */
165
+ panel(ctx: ExtensionContext): Promise<void>;
166
+ /** One action on one open delegation, for `/delegate-open`. */
167
+ openDelegate(ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void>;
168
+ /** Ids of the delegations whose window is open, for completions. */
169
+ openIds(): Promise<string[]>;
170
+ };
171
+
172
+ /** `/delegate-open`'s arguments: an id and at most one mode flag. */
173
+ export function parseOpen(text: string): { id: string; mode: OpenMode } | { error: string } {
174
+ const words = text.trim() === "" ? [] : text.trim().split(/\s+/);
175
+ const flags = words.filter((word) => word.startsWith("-"));
176
+ const ids = words.filter((word) => !word.startsWith("-"));
177
+ const unknown = flags.find((flag) => !(OPEN_FLAGS as readonly string[]).includes(flag));
178
+ 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}` };
180
+ if (ids.length !== 1) return { error: `give one delegation id; ${OPEN_USAGE}` };
181
+ const flag = flags[0];
182
+ return { id: ids[0], mode: flag === undefined ? "auto" : (flag.slice(2) as OpenMode) };
183
+ }
184
+
185
+ /** Completions for `/delegate-open`: ids first, then the mode flags not yet given. */
186
+ export function openCompletions(prefix: string, ids: readonly string[]): { value: string; label: string }[] | null {
187
+ const partial = /(?:^|\s)(\S*)$/.exec(prefix)?.[1] ?? "";
188
+ const head = prefix.slice(0, prefix.length - partial.length);
189
+ const given = head.trim() === "" ? [] : head.trim().split(/\s+/);
190
+ const values: string[] = [];
191
+ if (!given.some((word) => !word.startsWith("-"))) values.push(...ids);
192
+ if (!given.some((word) => word.startsWith("-"))) values.push(...OPEN_FLAGS);
193
+ const hits = values.filter((value) => value.startsWith(partial));
194
+ return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
195
+ }
196
+
197
+ type Env = { results: Results; tmux: TmuxClient; tracking: Pick<Tracking, "trackPopup" | "close"> };
198
+
199
+ /** The open delegations, newest first, from the records and one listing; undefined when tmux cannot be asked. */
200
+ async function openDelegates({ results, tmux }: Env): Promise<OpenDelegate[] | undefined> {
201
+ const windows = await listWindows(tmux);
202
+ if (windows === undefined) return undefined;
203
+ const found: OpenDelegate[] = [];
204
+ for (const record of results.list()) {
205
+ if (!isStarted(record)) continue;
206
+ const window = ownWindow(windows, record);
207
+ if (window === undefined || window.exited) continue;
208
+ const state = deriveState(record, "open");
209
+ if (state !== "running" && state !== "done") continue;
210
+ found.push({ id: record.id, name: recordedName(record), agent: record.agent, state, window });
211
+ }
212
+ return found.reverse();
213
+ }
214
+
215
+ /** The parent's own session name, or undefined when tmux cannot say. */
216
+ async function ownSessionName(tmux: TmuxClient): Promise<string | undefined> {
217
+ try {
218
+ return (await tmux.ownSession())?.name;
219
+ } catch {
220
+ return undefined;
221
+ }
222
+ }
223
+
224
+ /** The most recently active client on the parent's session, or why there is none. */
225
+ async function clientOf(tmux: TmuxClient): Promise<{ client: string } | { refusal: string }> {
226
+ const session = await ownSessionName(tmux);
227
+ if (session === undefined) return { refusal: "could not tell which tmux session this pane is in" };
228
+ try {
229
+ const client = await tmux.clientFor(session);
230
+ return client === undefined ? { refusal: `no tmux client is attached to session ${session}` } : { client };
231
+ } catch (err) {
232
+ return { refusal: `could not list tmux clients: ${(err as Error).message}` };
233
+ }
234
+ }
235
+
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
+ async function switchTo(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
255
+ const found = await clientOf(env.tmux);
256
+ if ("refusal" in found) return ctx.ui.notify(`delegate: cannot switch: ${found.refusal}`, "warning");
257
+ try {
258
+ await env.tmux.switchTo(found.client, item.window.id);
259
+ } catch (err) {
260
+ return ctx.ui.notify(`delegate: could not switch: ${(err as Error).message}`, "error");
261
+ }
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);
269
+ }
270
+
271
+ /** Close through the `delegation_close` path, asking first only while the delegate runs. */
272
+ async function close(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
273
+ if (item.state === "running") {
274
+ const message = `${item.name} is still running. Close it and kill its window?`;
275
+ if (!(await ctx.ui.confirm("Close delegate", message))) return;
276
+ }
277
+ try {
278
+ ctx.ui.notify(`delegate: ${(await env.tracking.close(item.id)).text}`, "info");
279
+ } catch (err) {
280
+ ctx.ui.notify(`delegate: ${(err as Error).message}`, "error");
281
+ }
282
+ }
283
+
284
+ /** Show the panel; resolves with what the user chose. */
285
+ function show(ctx: ExtensionContext, items: readonly OpenDelegate[]): Promise<PanelResult> {
286
+ return ctx.ui.custom<PanelResult>(
287
+ (tui, theme, _keybindings, done): Panel =>
288
+ new Panel({
289
+ items,
290
+ theme: theme as unknown as PanelTheme,
291
+ done,
292
+ redraw: (): void => tui.requestRender(),
293
+ }),
294
+ );
295
+ }
296
+
297
+ /** Peek at a delegate's pane in an overlay; true when the user chose to open it. */
298
+ async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<boolean> {
299
+ const result = await ctx.ui.custom<PeekResult>(
300
+ (tui, theme, _keybindings, done): PeekView =>
301
+ new PeekView({
302
+ item,
303
+ theme: theme as unknown as PanelTheme,
304
+ read: (windowId: string): Promise<string> => env.tmux.capture(windowId),
305
+ done,
306
+ redraw: (): void => tui.requestRender(),
307
+ rows: (): number => tui.terminal.rows,
308
+ }),
309
+ PEEK_OPTIONS,
310
+ );
311
+ return result?.action === "open";
312
+ }
313
+
314
+ const noUi = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: this needs an interactive session", "error");
315
+ const noTmux = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: could not ask tmux for its windows", "error");
316
+
317
+ /** Carry out what the panel returned; true when the panel should open again. */
318
+ async function act(
319
+ env: Env,
320
+ startNew: (ctx: ExtensionContext) => Promise<void> | void,
321
+ ctx: ExtensionContext,
322
+ items: OpenDelegate[],
323
+ result: PanelResult,
324
+ ): Promise<boolean> {
325
+ if (result === undefined) return false;
326
+ if (result.action === "new") {
327
+ await startNew(ctx);
328
+ return false;
329
+ }
330
+ const item = items.find((candidate) => candidate.id === result.id);
331
+ if (item === undefined) return false;
332
+ if (result.action === "close") {
333
+ await close(env, ctx, item);
334
+ return true;
335
+ }
336
+ if (result.action === "peek") {
337
+ if (!(await peek(env, ctx, item))) return true; // back to the panel
338
+ await open(env, ctx, item);
339
+ return false;
340
+ }
341
+ await (result.action === "switch" ? switchTo(env, ctx, item) : open(env, ctx, item));
342
+ return false;
343
+ }
344
+
345
+ /** One action on one open delegate, as `/delegate-open` runs it. */
346
+ async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void> {
347
+ const items = await openDelegates(env);
348
+ if (items === undefined) return noTmux(ctx);
349
+ const item = items.find((candidate) => candidate.id === id);
350
+ if (item === undefined) {
351
+ const known = items.map((candidate) => candidate.id).join(", ") || "none";
352
+ return ctx.ui.notify(`delegate: no open delegate ${JSON.stringify(id)}; open: ${known}`, "error");
353
+ }
354
+ if (mode === "switch") return switchTo(env, ctx, item);
355
+ if (mode !== "peek") return open(env, ctx, item); // --popup follows enter's rule too
356
+ if (!ctx.hasUI) return noUi(ctx);
357
+ if (await peek(env, ctx, item)) await open(env, ctx, item);
358
+ }
359
+
360
+ /** The navigator over the records, tmux and tracking. `startNew` is the new-delegate flow, wired by the entry. */
361
+ export function createNavigator(
362
+ results: Results,
363
+ tmux: TmuxClient,
364
+ tracking: Pick<Tracking, "trackPopup" | "close">,
365
+ startNew: (ctx: ExtensionContext) => Promise<void> | void,
366
+ ): Navigator {
367
+ const env: Env = { results, tmux, tracking };
368
+ return {
369
+ async panel(ctx: ExtensionContext): Promise<void> {
370
+ if (!ctx.hasUI) return noUi(ctx);
371
+ for (;;) {
372
+ const items = await openDelegates(env);
373
+ if (items === undefined) return noTmux(ctx);
374
+ if (!(await act(env, startNew, ctx, items, await show(ctx, items)))) return;
375
+ }
376
+ },
377
+ openDelegate: (ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void> => openOne(env, ctx, id, mode),
378
+ async openIds(): Promise<string[]> {
379
+ return (await openDelegates(env))?.map((item) => item.id) ?? [];
380
+ },
381
+ };
382
+ }