pi-minimal 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -1,4 +1,8 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { showBackgroundOverlay } from "./background-overlay.js";
1
3
  import { installCodeBox } from "./code-box.js";
4
+ import { isPrCreateCommand } from "./prs.js";
5
+ import { parsePrFilter, prsToolResult, runPrsCommand } from "./prs-command.js";
2
6
  import { createSession } from "./session.js";
3
7
  import { installToolRenderers, stopLiveWatch } from "./tools.js";
4
8
  import { installTranscript } from "./transcript.js";
@@ -17,9 +21,61 @@ export default function piMinimalExtension(pi) {
17
21
  installCodeBox();
18
22
  let active;
19
23
  let busy = false;
24
+ // Background processes: `/bg` and a shortcut (default ctrl+b, see `backgroundShortcut`). The
25
+ // shortcut is registered once per key at load, since pi has no unregister; it reads the active
26
+ // session's config when pressed.
27
+ const openBackground = async (ctx) => {
28
+ if (!active?.background)
29
+ return;
30
+ await showBackgroundOverlay(ctx, active.background, active.backgroundShortcut ?? undefined);
31
+ };
32
+ pi.registerCommand("bg", { description: "Show processes left running by bash tool calls", handler: (_args, ctx) => openBackground(ctx) });
33
+ // Open PRs for the repo in cwd: `/prs [mine|review|all]` and the `list_open_prs` tool.
34
+ pi.registerCommand("prs", {
35
+ description: "Open PRs in this repo that I authored or that need my review",
36
+ handler: (args, ctx) => runPrsCommand(args, ctx, pi, active?.prs),
37
+ });
38
+ pi.registerTool({
39
+ name: "list_open_prs",
40
+ label: "List Open PRs",
41
+ description: "List open pull requests in the current repository that the user authored or has been asked to review (via the gh CLI).",
42
+ parameters: Type.Object({
43
+ filter: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("mine"), Type.Literal("review")], { description: "all (default), mine, or review (waiting on my review)" })),
44
+ }),
45
+ async execute(_toolCallId, params) {
46
+ await active?.prs?.refresh();
47
+ const result = prsToolResult(active?.prs, params.filter ?? parsePrFilter(""));
48
+ const text = result.prs.length === 0
49
+ ? `No open PRs${result.repo ? ` in ${result.repo}` : ""}${result.error ? ` (${result.error})` : ""}.`
50
+ : result.prs.map((pr) => `#${pr.number} [${pr.kind}${pr.isDraft ? ", draft" : ""}${pr.ciFailing ? ", CI failing" : ""}] ${pr.title} — ${pr.url}`).join("\n");
51
+ return { content: [{ type: "text", text }], details: result };
52
+ },
53
+ });
54
+ const registeredShortcuts = new Set();
55
+ const ensureShortcut = () => {
56
+ const key = active?.backgroundShortcut;
57
+ if (!key || registeredShortcuts.has(key) || typeof pi.registerShortcut !== "function")
58
+ return;
59
+ registeredShortcuts.add(key);
60
+ pi.registerShortcut(key, {
61
+ description: "Show background processes",
62
+ handler: (ctx) => (active?.backgroundShortcut === key ? openBackground(ctx) : undefined),
63
+ });
64
+ };
20
65
  pi.on("session_start", (_event, ctx) => {
21
66
  active?.dispose();
22
67
  active = createSession(pi, ctx, { busy: () => busy });
68
+ ensureShortcut();
69
+ });
70
+ // Commands that create a PR: remember the URL they print so the chips follow the session
71
+ // even when cwd is another repo (worktrees) or no repo at all.
72
+ const prCreateCalls = new Map();
73
+ pi.on("tool_execution_start", (event) => {
74
+ active?.background?.toolStart(event.toolCallId, event.toolName);
75
+ const command = event.args?.command;
76
+ if ((event.toolName === "bash" || event.toolName === "powershell") && typeof command === "string" && isPrCreateCommand(command)) {
77
+ prCreateCalls.set(event.toolCallId, true);
78
+ }
23
79
  });
24
80
  pi.on("agent_start", () => {
25
81
  busy = true;
@@ -32,7 +88,12 @@ export default function piMinimalExtension(pi) {
32
88
  active?.pin("turn_end");
33
89
  active?.refreshGit();
34
90
  });
35
- pi.on("tool_execution_end", () => {
91
+ pi.on("tool_execution_end", (event) => {
92
+ active?.background?.toolEnd(event.toolCallId, event.toolName);
93
+ if (prCreateCalls.delete(event.toolCallId) && !event.isError) {
94
+ const content = event.result?.content ?? [];
95
+ active?.notePrUrls(content.map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : "")).join("\n"));
96
+ }
36
97
  active?.pin("tool_execution_end");
37
98
  active?.refreshGit();
38
99
  });
@@ -0,0 +1,23 @@
1
+ /** A tiny change-notification set shared by the git and sibling sources. */
2
+ export function createListeners() {
3
+ const listeners = new Set();
4
+ return {
5
+ notify() {
6
+ for (const listener of listeners) {
7
+ try {
8
+ listener();
9
+ }
10
+ catch {
11
+ // One listener's failure must not starve the others.
12
+ }
13
+ }
14
+ },
15
+ add(listener) {
16
+ listeners.add(listener);
17
+ return () => listeners.delete(listener);
18
+ },
19
+ clear() {
20
+ listeners.clear();
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,76 @@
1
+ import { describePr, PR_LEGEND } from "./prs.js";
2
+ export function parsePrFilter(args) {
3
+ const word = args.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
4
+ if (["mine", "author", "authored", "my"].includes(word))
5
+ return "mine";
6
+ if (["review", "reviews", "review-requested", "requested"].includes(word))
7
+ return "review";
8
+ return "all";
9
+ }
10
+ export function filterPrs(prs, filter) {
11
+ if (filter === "all")
12
+ return prs;
13
+ return prs.filter((pr) => pr.kind === filter);
14
+ }
15
+ const ACTIONS = ["Open in browser", "Paste URL into editor", "Checkout branch", "Cancel"];
16
+ export async function runPrsCommand(args, ctx, pi, source) {
17
+ if (!source) {
18
+ ctx.ui.notify("PRs are only tracked in interactive sessions.", "info");
19
+ return;
20
+ }
21
+ await source.refresh(true);
22
+ const snap = source.get();
23
+ if (!snap.repo && snap.prs.length === 0) {
24
+ ctx.ui.notify(snap.error ? `No GitHub repo here: ${snap.error}` : "No GitHub repo here.", "warning");
25
+ return;
26
+ }
27
+ const filter = parsePrFilter(args);
28
+ const prs = filterPrs(snap.prs, filter);
29
+ const title = `${snap.repo?.nameWithOwner ?? "this session"}: ${prs.length} open PR${prs.length === 1 ? "" : "s"}${filter === "all" ? "" : ` (${filter})`}${snap.error ? ` · stale: ${snap.error}` : ""}`;
30
+ if (prs.length === 0) {
31
+ ctx.ui.notify(title, "info");
32
+ return;
33
+ }
34
+ if (ctx.mode !== "tui") {
35
+ ctx.ui.notify(`${title}\n${prs.map((pr) => `${describePr(pr, snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined)}\n ${pr.url}`).join("\n")}`, "info");
36
+ return;
37
+ }
38
+ const cwdRepo = snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined;
39
+ const labels = prs.map((pr) => describePr(pr, cwdRepo));
40
+ const chosen = await ctx.ui.select(`${title}\n${PR_LEGEND}`, labels);
41
+ if (chosen === undefined)
42
+ return;
43
+ const pr = prs[labels.indexOf(chosen)];
44
+ if (!pr)
45
+ return;
46
+ const action = await ctx.ui.select(`#${pr.number} ${pr.title}`, [...ACTIONS]);
47
+ if (action === "Open in browser") {
48
+ const open = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
49
+ const run = await pi.exec(open, [pr.url]);
50
+ if (run.code !== 0)
51
+ ctx.ui.notify(`Could not open browser: ${run.stderr.trim()}`, "error");
52
+ }
53
+ else if (action === "Paste URL into editor") {
54
+ ctx.ui.pasteToEditor(pr.url);
55
+ }
56
+ else if (action === "Checkout branch") {
57
+ const run = await pi.exec("gh", ["pr", "checkout", String(pr.number), "-R", pr.repo], { cwd: ctx.cwd, timeout: 60_000 });
58
+ ctx.ui.notify(run.code === 0 ? `Checked out #${pr.number}` : `Checkout failed: ${run.stderr.trim()}`, run.code === 0 ? "info" : "error");
59
+ }
60
+ }
61
+ export function prsToolResult(source, filter) {
62
+ const snap = source?.get();
63
+ const prs = filterPrs(snap?.prs ?? [], filter).map((pr) => ({
64
+ number: pr.number,
65
+ title: pr.title,
66
+ url: pr.url,
67
+ repo: pr.repo,
68
+ author: pr.author,
69
+ isDraft: pr.isDraft,
70
+ kind: pr.kind,
71
+ reviewDecision: pr.reviewDecision,
72
+ ciFailing: pr.ciFailing,
73
+ updatedAt: pr.updatedAt,
74
+ }));
75
+ return { repo: snap?.repo?.nameWithOwner, host: snap?.repo?.host, fetchedAt: snap?.fetchedAt, error: snap?.error, prs };
76
+ }
package/dist/prs.js ADDED
@@ -0,0 +1,270 @@
1
+ import { hyperlink } from "@earendil-works/pi-tui";
2
+ import { createListeners } from "./listeners.js";
3
+ export const PR_LIST_FIELDS = "number,title,url,isDraft,author,reviewRequests,reviewDecision,statusCheckRollup,updatedAt";
4
+ /** A GitHub PR URL on any host: `https://host/owner/repo/pull/123`. */
5
+ export const PR_URL_RE = /https?:\/\/([a-z0-9.-]+)\/([^\s/]+)\/([^\s/]+)\/pull\/(\d+)\b/gi;
6
+ /** Finds PR URLs in text, e.g. the output of `gh pr create`. */
7
+ export function findPrUrls(text) {
8
+ const urls = new Set();
9
+ for (const match of text.matchAll(PR_URL_RE))
10
+ urls.add(`https://${match[1]}/${match[2]}/${match[3]}/pull/${match[4]}`);
11
+ return [...urls];
12
+ }
13
+ /** Repo (`host/owner/repo`) a PR URL belongs to. */
14
+ export function prUrlRepo(url) {
15
+ const match = new RegExp(PR_URL_RE.source, "i").exec(url);
16
+ return match ? `${match[1]}/${match[2]}/${match[3]}` : undefined;
17
+ }
18
+ /** Commands whose output announces a PR this session created. */
19
+ export function isPrCreateCommand(command) {
20
+ return /\bgh\s+pr\s+create\b|\bgt\s+submit\b/.test(command);
21
+ }
22
+ /** Parses `gh repo view --json nameWithOwner,url`. */
23
+ export function parseRepoView(stdout) {
24
+ const data = JSON.parse(stdout);
25
+ if (typeof data.nameWithOwner !== "string" || typeof data.url !== "string")
26
+ throw new Error("invalid gh repo view");
27
+ return { nameWithOwner: data.nameWithOwner, host: new URL(data.url).host, url: data.url };
28
+ }
29
+ const FAILED_STATES = new Set(["FAILURE", "ERROR"]);
30
+ const FAILED_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
31
+ export function ciFailing(checks) {
32
+ return (checks ?? []).some((check) => FAILED_STATES.has(check.state ?? "") || FAILED_CONCLUSIONS.has(check.conclusion ?? ""));
33
+ }
34
+ function toPullRequest(raw, repo, kind) {
35
+ if (typeof raw.number !== "number" || typeof raw.url !== "string")
36
+ return undefined;
37
+ return {
38
+ number: raw.number,
39
+ title: typeof raw.title === "string" ? raw.title : "",
40
+ url: raw.url,
41
+ repo,
42
+ author: typeof raw.author?.login === "string" ? raw.author.login : "",
43
+ isDraft: raw.isDraft === true,
44
+ kind,
45
+ reviewDecision: typeof raw.reviewDecision === "string" ? raw.reviewDecision : "",
46
+ ciFailing: ciFailing(raw.statusCheckRollup),
47
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "",
48
+ };
49
+ }
50
+ /** Parses `gh pr view URL --json …` for a PR this session created (always `mine`). */
51
+ export function parsePrView(stdout, url) {
52
+ const raw = JSON.parse(stdout);
53
+ if (raw.state !== undefined && raw.state !== "OPEN")
54
+ return undefined;
55
+ const repo = prUrlRepo(url);
56
+ return repo ? toPullRequest(raw, repo, "mine") : undefined;
57
+ }
58
+ /**
59
+ * Parses `gh pr list --json …` for `involves:@me` into my PRs: authored by `login`, or requesting
60
+ * my review (directly; team requests are included when `teams` lists my team slugs). Others I'm
61
+ * merely mentioned on are dropped.
62
+ */
63
+ export function parsePrList(stdout, repo, login, teams = []) {
64
+ const data = JSON.parse(stdout);
65
+ if (!Array.isArray(data))
66
+ throw new Error("invalid gh pr list");
67
+ const teamSet = new Set(teams.map((team) => team.toLowerCase()));
68
+ const prs = [];
69
+ for (const raw of data) {
70
+ const author = typeof raw.author?.login === "string" ? raw.author.login : "";
71
+ const requested = (raw.reviewRequests ?? []).some((request) => {
72
+ if (typeof request.login === "string")
73
+ return request.login.toLowerCase() === login.toLowerCase();
74
+ const slug = typeof request.slug === "string" ? request.slug : typeof request.name === "string" ? request.name : "";
75
+ return teamSet.has(slug.toLowerCase());
76
+ });
77
+ const mine = author.toLowerCase() === login.toLowerCase();
78
+ if (!mine && !requested)
79
+ continue;
80
+ const pr = toPullRequest(raw, `${repo.host}/${repo.nameWithOwner}`, mine ? "mine" : "review");
81
+ if (pr)
82
+ prs.push(pr);
83
+ }
84
+ // Review requests first (they need me), then mine; newest first within each.
85
+ prs.sort((a, b) => (a.kind === b.kind ? (a.updatedAt < b.updatedAt ? 1 : -1) : a.kind === "review" ? -1 : 1));
86
+ return prs;
87
+ }
88
+ /** Chip colour: trouble (failing CI, changes requested) wins, then review requests, then drafts. */
89
+ export function prColor(pr) {
90
+ if (pr.ciFailing || pr.reviewDecision === "CHANGES_REQUESTED")
91
+ return "error";
92
+ if (pr.kind === "review")
93
+ return "warning";
94
+ if (pr.isDraft)
95
+ return "dim";
96
+ return "accent";
97
+ }
98
+ export const PR_LEGEND = "#n mine · dim draft · amber needs my review · red failing CI / changes requested";
99
+ /** `#1675` inside the cwd repo; `repo#1675` for a PR elsewhere (created in this session) or when cwd is no repo. */
100
+ export function prChipLabel(pr, cwdRepo) {
101
+ if (cwdRepo && pr.repo === cwdRepo)
102
+ return `#${pr.number}`;
103
+ return `${pr.repo.split("/").pop()}#${pr.number}`;
104
+ }
105
+ /** Footer segment: `#1675 #1670 +3` with each number an OSC 8 link; undefined when empty. */
106
+ export function prSegment(prs, theme, max = 4, cwdRepo) {
107
+ if (prs.length === 0)
108
+ return undefined;
109
+ const shown = prs.slice(0, max);
110
+ const chips = shown.map((pr) => theme.fg(prColor(pr), hyperlink(prChipLabel(pr, cwdRepo), pr.url)));
111
+ const rest = prs.length - shown.length;
112
+ if (rest > 0)
113
+ chips.push(theme.fg("dim", `+${rest}`));
114
+ return chips.join(" ");
115
+ }
116
+ export function describePr(pr, cwdRepo) {
117
+ const flags = [
118
+ pr.kind === "review" ? "review requested" : pr.isDraft ? "draft" : "mine",
119
+ pr.ciFailing ? "CI failing" : undefined,
120
+ pr.reviewDecision === "CHANGES_REQUESTED" ? "changes requested" : pr.reviewDecision === "APPROVED" ? "approved" : undefined,
121
+ ].filter(Boolean);
122
+ return `${prChipLabel(pr, cwdRepo)} ${pr.title} · ${flags.join(" · ")}${pr.kind === "review" ? ` · @${pr.author}` : ""}`;
123
+ }
124
+ const GH_TIMEOUT_MS = 15_000;
125
+ const DEFAULT_POLL_MS = 3 * 60_000;
126
+ const DEFAULT_MIN_INTERVAL_MS = 60_000;
127
+ export function createPrSource(deps) {
128
+ const now = deps.now ?? Date.now;
129
+ const pollMs = deps.pollMs ?? DEFAULT_POLL_MS;
130
+ const minIntervalMs = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
131
+ const listeners = createListeners();
132
+ let alive = true;
133
+ let state = { prs: [] };
134
+ let inFlight;
135
+ let timer;
136
+ let lastRun = 0;
137
+ const logins = new Map();
138
+ const session = new Set(deps.sessionPrs ?? []);
139
+ // Only what is displayed counts as a change; a refresh that confirms the same list is silent.
140
+ const visible = (snap) => JSON.stringify([snap.repo, snap.prs, snap.error]);
141
+ const set = (next) => {
142
+ const changed = visible(next) !== visible(state);
143
+ state = next;
144
+ if (changed)
145
+ listeners.notify();
146
+ };
147
+ const gh = (args) => deps.pi.exec("gh", args, { cwd: deps.cwd(), timeout: GH_TIMEOUT_MS });
148
+ const viewerLogin = async (host) => {
149
+ const cached = logins.get(host);
150
+ if (cached)
151
+ return cached;
152
+ const run = await gh(["api", "--hostname", host, "user", "--jq", ".login"]);
153
+ if (run.code !== 0)
154
+ return undefined;
155
+ const login = run.stdout.trim();
156
+ if (login)
157
+ logins.set(host, login);
158
+ return login || undefined;
159
+ };
160
+ /** PRs this session created, wherever they live; a merged or closed one is forgotten. */
161
+ const fetchSessionPrs = async (exclude) => {
162
+ const extra = await Promise.all([...session].filter((url) => !exclude.has(url)).map(async (url) => {
163
+ const view = await gh(["pr", "view", url, "--json", `${PR_LIST_FIELDS},state`]);
164
+ if (view.code !== 0)
165
+ return undefined;
166
+ try {
167
+ const pr = parsePrView(view.stdout, url);
168
+ if (!pr)
169
+ session.delete(url);
170
+ return pr;
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ }));
176
+ return extra.filter((pr) => pr !== undefined);
177
+ };
178
+ const fetchOnce = async () => {
179
+ let repo = state.repo;
180
+ let repoError;
181
+ try {
182
+ if (!repo) {
183
+ const view = await gh(["repo", "view", "--json", "nameWithOwner,url"]);
184
+ if (!alive)
185
+ return;
186
+ if (view.code === 0)
187
+ repo = parseRepoView(view.stdout);
188
+ else
189
+ repoError = view.stderr.trim() || "gh repo view failed";
190
+ }
191
+ let prs = [];
192
+ let error = repoError;
193
+ if (repo) {
194
+ const login = await viewerLogin(repo.host);
195
+ if (!alive)
196
+ return;
197
+ if (!login) {
198
+ error = `gh is not authenticated for ${repo.host}`;
199
+ }
200
+ else {
201
+ const list = await gh([
202
+ "pr", "list", "-R", `${repo.host}/${repo.nameWithOwner}`, "--state", "open", "--limit", "100",
203
+ "--search", "involves:@me", "--json", PR_LIST_FIELDS,
204
+ ]);
205
+ if (!alive)
206
+ return;
207
+ if (list.code !== 0)
208
+ error = list.stderr.trim() || "gh pr list failed";
209
+ else
210
+ prs = parsePrList(list.stdout, repo, login);
211
+ }
212
+ }
213
+ // Outside a GitHub repo, or when the list failed, the session's own PRs still show.
214
+ prs.push(...(await fetchSessionPrs(new Set(prs.map((pr) => pr.url)))));
215
+ if (!alive)
216
+ return;
217
+ set({ repo, prs: error && prs.length === 0 && state.prs.length > 0 ? state.prs : prs, error, fetchedAt: now() });
218
+ }
219
+ catch (error) {
220
+ if (!alive)
221
+ return;
222
+ set({ repo, prs: state.prs, error: error instanceof Error ? error.message : String(error), fetchedAt: state.fetchedAt });
223
+ }
224
+ };
225
+ const schedule = () => {
226
+ if (!alive)
227
+ return;
228
+ if (timer)
229
+ clearTimeout(timer);
230
+ timer = setTimeout(() => { void refresh(true); }, pollMs);
231
+ timer.unref?.();
232
+ };
233
+ const refresh = (force = false) => {
234
+ if (!alive)
235
+ return Promise.resolve();
236
+ if (inFlight)
237
+ return inFlight;
238
+ if (!force && now() - lastRun < minIntervalMs)
239
+ return Promise.resolve();
240
+ lastRun = now();
241
+ inFlight = fetchOnce().finally(() => {
242
+ inFlight = undefined;
243
+ schedule();
244
+ });
245
+ return inFlight;
246
+ };
247
+ void refresh(true);
248
+ return {
249
+ get: () => state,
250
+ onChange: listeners.add,
251
+ refresh,
252
+ addSessionPr(url) {
253
+ const found = findPrUrls(url)[0];
254
+ if (!found || session.has(found))
255
+ return;
256
+ session.add(found);
257
+ void refresh(true);
258
+ },
259
+ sessionPrs: () => [...session],
260
+ dispose() {
261
+ if (!alive)
262
+ return;
263
+ alive = false;
264
+ if (timer)
265
+ clearTimeout(timer);
266
+ timer = undefined;
267
+ listeners.clear();
268
+ },
269
+ };
270
+ }