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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.13.0] - 2026-10-26
10
+
11
+ ### Added
12
+
13
+ - `/delegates`, also on `alt+shift+d`, opens a panel of the delegations whose
14
+ window is open (newest first): `enter` opens one in a tmux popup, or switches
15
+ to it when it is in your own session; `p` peeks at its pane in an overlay (`r` refreshes,
16
+ `enter` opens); `s` switches your client to it; `x` closes it, asking only
17
+ while it runs; `n` opens the new-delegate form. `/delegate-open <id> [--popup|--switch|--peek]` does one of
18
+ these directly. Popup and switch act on the most recently active client of the
19
+ parent's session; the parent closes a popup once its delegate's window is gone.
20
+ Commands and a shortcut only: the model gets no new tool.
21
+ - `/delegate` with no arguments opens a new-delegate form: pick an agent, write
22
+ the task, name it, then a settings screen for model, thinking level (only the
23
+ levels the model supports, clamped, visibly, when the agent's default or a new model does not support it), auto-exit,
24
+ working directory and worktree. It launches through the same path as the
25
+ typed command. `/delegate-resume` with no arguments opens a picker of the
26
+ delegations that can be resumed, then an editor for the follow-up. Typed
27
+ commands and the tools are unchanged; the two commands no longer print
28
+ their usage when typed bare.
29
+ - `@earendil-works/pi-ai` is now a peer dependency (`*`), supplied by Pi.
30
+
9
31
  ## [0.12.0] - 2026-10-26
10
32
 
11
33
  ### Changed
@@ -491,7 +513,8 @@ author's dotfiles; the changes below are against that copy.
491
513
 
492
514
  - The published file list includes `child.ts`, which `index.ts` imports.
493
515
 
494
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.12.0...HEAD
516
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.13.0...HEAD
517
+ [0.13.0]: https://github.com/gvanderclay/pi-squire/compare/v0.12.0...v0.13.0
495
518
  [0.12.0]: https://github.com/gvanderclay/pi-squire/compare/v0.11.0...v0.12.0
496
519
  [0.11.0]: https://github.com/gvanderclay/pi-squire/compare/v0.10.0...v0.11.0
497
520
  [0.10.0]: https://github.com/gvanderclay/pi-squire/compare/v0.9.5...v0.10.0
package/README.md CHANGED
@@ -75,6 +75,20 @@ When the delegate finishes, its answer reaches the parent as one `[delegate]`
75
75
  message that quotes the task (see [Results](#results)), and the delegate then
76
76
  closes its own window ([auto-exit](#auto-exit)).
77
77
 
78
+ `/delegate` with no arguments opens a form instead: pick an agent, write the
79
+ task in the multi-line editor, give it an optional label (empty for the
80
+ default name), then a settings screen. Its first row, Start, launches; the rows
81
+ below it all begin at the agent's defaults: Model (a filterable list of the
82
+ models your Pi has credentials for), Thinking (only the levels the selected
83
+ model supports; a level the model does not support, whether from the agent's
84
+ default or after changing the model, moves to the nearest supported one and says so), Auto-exit, Working directory (your own, to change)
85
+ and Worktree (a new branch name, empty for none). A worktree and a changed
86
+ working directory exclude each other; the base, source and fallbacks stay
87
+ command-only. The form starts the delegate through the same path as the
88
+ command, in the background, with the same start message. The label and
89
+ directory are checked as `--label` and `--cwd` are, before anything is sent
90
+ or opened.
91
+
78
92
  ## How a delegation works
79
93
 
80
94
  `/delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--cwd <dir> | --worktree <branch> [--worktree-source <dir>] [--worktree-base <commit-ish>]] [--auto-exit | --no-auto-exit] <task…>`
@@ -165,6 +179,34 @@ The values are `companion` (the default when the setting is absent) and
165
179
  `current`. The file is read at each launch. Any other value refuses the launch
166
180
  with an error naming the file, before the task is sent or a window opens.
167
181
 
182
+ ## Reaching delegates
183
+
184
+ Delegate windows sit out of your way, so reach them from the parent. `/delegates`
185
+ (or `alt+shift+d`) opens a panel of the delegations whose window is open: running,
186
+ or done with auto-exit off, newest first. The model has no tool for any of this.
187
+
188
+ | Key | Does |
189
+ |---|---|
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) |
194
+ | `x` | close the delegation as `delegation_close` does; asks first only while it is running |
195
+ | `n` | new delegate: the same form `/delegate` opens with no arguments (see [First use](#first-use)) |
196
+
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.
203
+
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.
209
+
168
210
  ## Working directory
169
211
 
170
212
  `--cwd <dir>` (the `delegate` tool's `cwd`) runs the delegate in an existing
@@ -826,6 +868,15 @@ The model does the same with `delegation_resume` (`id` and `task`, optionally
826
868
  `model`, `thinking` and `auto_exit`), which returns the `delegate` tool's result
827
869
  plus `resumedFrom`. The command completes the ids that can still be resumed.
828
870
 
871
+ `/delegate-resume` with no arguments opens a picker instead: the delegations
872
+ that pass every check below (newest first, from one look at tmux), then the
873
+ multi-line editor for the follow-up. The checks change nothing: it refuses as the
874
+ command does when tmux is older than 3.2 or `windowPlacement` is invalid, and leaves out a
875
+ delegation whose recorded thinking level is invalid or whose model and fallbacks cannot be used
876
+ (credentials, usage limits). An exited window is left for the resume to close, and trust is
877
+ asked only when you resume. The model, thinking level and auto-exit are the delegation's own,
878
+ and it resumes as the command does.
879
+
829
880
  - The delegate's own session file is reopened with `pi --session <absolute path>`
830
881
  and the recorded `--session-dir`, in a new window named like the first. The
831
882
  delegate keeps everything it read and decided; Pi appends to the same file.
@@ -993,7 +1044,11 @@ Other extensions may rely on:
993
1044
  - the `delegate`, `delegation_status`, `delegation_close` and `delegation_resume` tool names,
994
1045
  parameters, the recorded `workspace` shape and the `workspaceSnapshot`
995
1046
  shape in results and status;
996
- - the `/delegate` and `/delegate-resume` syntax and flags;
1047
+ - the `/delegate` and `/delegate-resume` syntax and flags, and their
1048
+ no-argument forms: `/delegate` opens the new-delegate form and
1049
+ `/delegate-resume` the resume picker;
1050
+ - the `/delegates` command and its `alt+shift+d` shortcut, and `/delegate-open <id>` with its
1051
+ `--popup`, `--switch` and `--peek` flags;
997
1052
  - the `[delegate]` result text and the closed-without-a-result text;
998
1053
  - `AGENT.md`'s frontmatter keys and its location,
999
1054
  `<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.12.0",
3
+ "version": "0.13.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",
@@ -44,12 +44,14 @@
44
44
  "access": "public"
45
45
  },
46
46
  "peerDependencies": {
47
+ "@earendil-works/pi-ai": "*",
47
48
  "@earendil-works/pi-coding-agent": ">=1.3.0",
48
49
  "@earendil-works/pi-tui": "*",
49
50
  "typebox": "*"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@biomejs/biome": "2.5.14",
54
+ "@earendil-works/pi-ai": "1.3.0",
53
55
  "@earendil-works/pi-coding-agent": "1.3.0",
54
56
  "@earendil-works/pi-tui": "1.3.0",
55
57
  "@types/node": "26.6.3",
package/src/index.ts CHANGED
@@ -53,9 +53,12 @@ import { Type } from "typebox";
53
53
  import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
54
54
  import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
55
55
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
56
+ import { createNavigator, OPEN_USAGE, openCompletions, parseOpen } from "./navigator.ts";
57
+ import { type NewDelegateDeps, type NewRequest, startNewDelegate } from "./new-delegate.ts";
56
58
  import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
57
59
  import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
58
60
  import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
61
+ import { pickResume } from "./resume-picker.ts";
59
62
  import { createTmuxClient, type OpenedWindow, requireTmuxVersion, type TmuxClient, WindowOpenError } from "./tmux.ts";
60
63
  import { createTracking, toolResult } from "./tracking.ts";
61
64
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
@@ -587,19 +590,19 @@ export default function delegate(
587
590
  return roster;
588
591
  }
589
592
 
593
+ /** The refusal for an empty roster. */
594
+ const noAgents = (): string =>
595
+ `no agents are defined; add ${join(
596
+ getAgentDir(),
597
+ "squire",
598
+ "agents",
599
+ "<name>",
600
+ "AGENT.md",
601
+ )} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback), and the prompt as its body`;
602
+
590
603
  /** The request as a start, or an error naming what to fix. */
591
604
  async function validate(request: Parsed, roster: Roster, models: ModelRegistry): Promise<Start> {
592
- if (roster.agents.length === 0) {
593
- throw new Error(
594
- `no agents are defined; add ${join(
595
- getAgentDir(),
596
- "squire",
597
- "agents",
598
- "<name>",
599
- "AGENT.md",
600
- )} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback), and the prompt as its body`,
601
- );
602
- }
605
+ if (roster.agents.length === 0) throw new Error(noAgents());
603
606
  const agent = roster.agents.find((candidate) => candidate.name === request.agent);
604
607
  if (agent === undefined) {
605
608
  throw new Error(
@@ -1009,22 +1012,18 @@ export default function delegate(
1009
1012
  const started = (launched: Launched, start: Start): string =>
1010
1013
  `delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${inSession(launched.windowSession)}${placedIn(launched)}${launched.workspace.mode === "managed" ? `. Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
1011
1014
 
1012
- async function command(input: string, ctx: ExtensionContext): Promise<void> {
1015
+ /** Start a delegate from a parsed `/delegate` or the form, telling the user how it went. */
1016
+ async function runDelegate(parsed: Parsed, ctx: ExtensionContext): Promise<void> {
1013
1017
  const fail = (message: string) => ctx.ui.notify(`delegate: ${message}`, "error");
1014
- const outcome = parse(input);
1015
- if ("error" in outcome) {
1016
- fail(outcome.error);
1017
- return;
1018
- }
1019
1018
  try {
1020
- const cwd = normalizeWorkspaceValue(outcome.parsed.cwd, "--cwd");
1021
- const worktree_source = normalizeWorkspaceValue(outcome.parsed.worktree_source, "--worktree-source");
1022
- const worktree_base = normalizeWorkspaceValue(outcome.parsed.worktree_base, "--worktree-base");
1023
- const worktree = normalizeWorkspaceValue(outcome.parsed.worktree, "--worktree");
1019
+ const cwd = normalizeWorkspaceValue(parsed.cwd, "--cwd");
1020
+ const worktree_source = normalizeWorkspaceValue(parsed.worktree_source, "--worktree-source");
1021
+ const worktree_base = normalizeWorkspaceValue(parsed.worktree_base, "--worktree-base");
1022
+ const worktree = normalizeWorkspaceValue(parsed.worktree, "--worktree");
1024
1023
  if (worktree_source !== undefined && worktree === undefined)
1025
1024
  throw new Error("--worktree-source requires --worktree");
1026
1025
  const start = await validate(
1027
- { ...outcome.parsed, cwd, worktree, worktree_base, worktree_source },
1026
+ { ...parsed, cwd, worktree, worktree_base, worktree_source },
1028
1027
  rosterFor(ctx),
1029
1028
  ctx.modelRegistry,
1030
1029
  );
@@ -1035,7 +1034,44 @@ export default function delegate(
1035
1034
  }
1036
1035
  }
1037
1036
 
1037
+ /** What the new-delegate form needs, shared by `/delegate` and the panel's `n`. */
1038
+ const newDelegateDeps: NewDelegateDeps = {
1039
+ unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1040
+ roster: rosterFor,
1041
+ noAgents,
1042
+ labelProblem,
1043
+ start: (request: NewRequest, context) => runDelegate(request, context),
1044
+ };
1045
+
1046
+ /** `/delegate`: with no arguments the new-delegate form, otherwise the typed request. */
1047
+ async function command(input: string, ctx: ExtensionContext): Promise<void> {
1048
+ if (input.trim() === "") {
1049
+ await startNewDelegate(ctx, newDelegateDeps);
1050
+ return;
1051
+ }
1052
+ const outcome = parse(input);
1053
+ if ("error" in outcome) {
1054
+ ctx.ui.notify(`delegate: ${outcome.error}`, "error");
1055
+ return;
1056
+ }
1057
+ await runDelegate(outcome.parsed, ctx);
1058
+ }
1059
+
1038
1060
  async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1061
+ if (input.trim() === "") {
1062
+ await pickResume(ctx, {
1063
+ unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1064
+ eligible: resumer.eligible,
1065
+ resume: async (request, context) => {
1066
+ try {
1067
+ context.ui.notify(startedLine(await resumer.resume(request, context)), "info");
1068
+ } catch (err) {
1069
+ context.ui.notify(`delegate: ${(err as Error).message}`, "error");
1070
+ }
1071
+ },
1072
+ });
1073
+ return;
1074
+ }
1039
1075
  const outcome = parseResume(input);
1040
1076
  if ("error" in outcome) {
1041
1077
  ctx.ui.notify(`delegate: ${outcome.error}`, "error");
@@ -1243,6 +1279,30 @@ export default function delegate(
1243
1279
  handler: resumeCommand,
1244
1280
  });
1245
1281
 
1282
+ const navigator = createNavigator(results, tmux, tracking, (ctx: ExtensionContext) =>
1283
+ startNewDelegate(ctx, newDelegateDeps),
1284
+ );
1285
+
1286
+ pi.registerCommand("delegates", {
1287
+ description: "Open the panel of delegates whose window is open: popup, peek, switch, close",
1288
+ handler: async (_args: string, ctx: ExtensionContext) => navigator.panel(ctx),
1289
+ });
1290
+
1291
+ pi.registerShortcut("alt+shift+d", {
1292
+ description: "Open the delegates panel",
1293
+ handler: (ctx: ExtensionContext) => navigator.panel(ctx),
1294
+ });
1295
+
1296
+ pi.registerCommand("delegate-open", {
1297
+ description: `Reach one open delegate: ${OPEN_USAGE}`,
1298
+ getArgumentCompletions: async (prefix: string) => openCompletions(prefix, await navigator.openIds()),
1299
+ handler: async (args: string, ctx: ExtensionContext) => {
1300
+ const parsed = parseOpen(args);
1301
+ if ("error" in parsed) return ctx.ui.notify(`delegate: ${parsed.error}`, "error");
1302
+ await navigator.openDelegate(ctx, parsed.id, parsed.mode);
1303
+ },
1304
+ });
1305
+
1246
1306
  pi.registerCommand("delegate-clear", {
1247
1307
  description: "Clear usage-limit marks: all, a provider, or a provider/model",
1248
1308
  getArgumentCompletions: (prefix: string) => {
@@ -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
+ }