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 +24 -1
- package/README.md +56 -1
- package/package.json +3 -1
- package/src/index.ts +82 -22
- package/src/navigator.ts +382 -0
- package/src/new-delegate.ts +438 -0
- package/src/poll.ts +111 -0
- package/src/popups.ts +49 -0
- package/src/resume-checks.ts +159 -0
- package/src/resume-picker.ts +50 -0
- package/src/resume.ts +39 -98
- package/src/tmux.ts +117 -1
- package/src/tracking.ts +57 -127
- package/src/windows.ts +24 -0
- package/src/workspace.ts +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
1021
|
-
const worktree_source = normalizeWorkspaceValue(
|
|
1022
|
-
const worktree_base = normalizeWorkspaceValue(
|
|
1023
|
-
const worktree = normalizeWorkspaceValue(
|
|
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
|
-
{ ...
|
|
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) => {
|
package/src/navigator.ts
ADDED
|
@@ -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
|
+
}
|