@saadjs/pi-git-status 0.1.0 → 0.2.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.
Files changed (4) hide show
  1. package/README.md +7 -1
  2. package/core.ts +73 -2
  3. package/index.ts +158 -15
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Git status in Pi's footer, alongside other extensions' statuses.
4
4
 
5
5
  ```text
6
- main • 3 changed • 2 untracked • +42 −17
6
+ feat • #3 • 3 changed • 2 untracked • +42 −17
7
7
  ```
8
8
 
9
9
  Clean trees show `main • clean`. The status is hidden outside Git repositories or when Git is unavailable.
@@ -31,6 +31,12 @@ Use an absolute path to `index.ts` when running Pi in another repository.
31
31
 
32
32
  Counts cover the whole repository, even from a subdirectory. Refreshes on session start, after successful `edit`/`write` tools, and after agent completion.
33
33
 
34
+ ## Pull request
35
+
36
+ `#3` is the GitHub PR for the current branch, found with `gh pr view`. It's a clickable link (OSC 8) in terminals that support them. Colors: green for open, muted for draft, accent for merged, red for closed. It's hidden on `main`/`master`, when there is no PR, and when `gh` is missing or signed out.
37
+
38
+ The PR is looked up when the session starts, when the branch changes, and after `git push`, `git checkout`, `git switch`, or `gh pr` commands. Between those, it's polled every 90s while you're active. Polling slows when you go idle, backs off when no PR is found, and stops after an hour without activity.
39
+
34
40
  Custom footers must display extension statuses via `footerData.getExtensionStatuses()`.
35
41
 
36
42
  ## Test
package/core.ts CHANGED
@@ -118,18 +118,89 @@ export async function readGitStatus(cwd: string, signal?: AbortSignal): Promise<
118
118
  }
119
119
  }
120
120
 
121
+ export type PullRequestState = "open" | "draft" | "merged" | "closed";
122
+
123
+ export interface PullRequest {
124
+ number: number;
125
+ url: string;
126
+ state: PullRequestState;
127
+ }
128
+
129
+ /** `null` means the branch has no PR; `"unavailable"` means gh is missing or signed out. */
130
+ export type PullRequestLookup = PullRequest | null | "unavailable";
131
+
132
+ export function parsePullRequest(output: string): PullRequest | null {
133
+ try {
134
+ const data: unknown = JSON.parse(output);
135
+ if (typeof data !== "object" || data === null) return null;
136
+ const { number, url, state, isDraft } = data as Record<string, unknown>;
137
+ if (typeof number !== "number" || typeof url !== "string" || !url.startsWith("https://")) {
138
+ return null;
139
+ }
140
+ if (state === "OPEN") return { number, url, state: isDraft === true ? "draft" : "open" };
141
+ if (state === "MERGED") return { number, url, state: "merged" };
142
+ if (state === "CLOSED") return { number, url, state: "closed" };
143
+ return null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /** Looks up the PR for the checked-out branch, including merged and closed ones. */
150
+ export function readPullRequest(cwd: string, signal?: AbortSignal): Promise<PullRequestLookup> {
151
+ return new Promise((resolve, reject) => {
152
+ execFile(
153
+ "gh",
154
+ ["pr", "view", "--json", "number,url,state,isDraft"],
155
+ {
156
+ cwd,
157
+ signal,
158
+ env: { ...process.env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
159
+ encoding: "utf8",
160
+ timeout: 10_000,
161
+ windowsHide: true,
162
+ },
163
+ (error, stdout, stderr) => {
164
+ if (!error) return resolve(parsePullRequest(stdout));
165
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return resolve("unavailable");
166
+ if (/no (pull requests|open pull requests) found/i.test(stderr)) return resolve(null);
167
+ if (/gh auth login|authentication|bad credentials/i.test(stderr)) {
168
+ return resolve("unavailable");
169
+ }
170
+ // Network errors, rate limits, non-GitHub remotes: let the caller keep the last result.
171
+ reject(error);
172
+ },
173
+ );
174
+ });
175
+ }
176
+
177
+ /** OSC 8 terminal hyperlink. */
178
+ export function hyperlink(url: string, text: string): string {
179
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
180
+ }
181
+
182
+ const PR_COLORS: Record<PullRequestState, StatusColor> = {
183
+ open: "success",
184
+ draft: "muted",
185
+ merged: "accent",
186
+ closed: "error",
187
+ };
188
+
121
189
  type StatusColor = "text" | "accent" | "muted" | "success" | "error";
122
190
 
123
191
  export function formatGitStatus(
124
192
  status: GitStatus,
125
193
  color: (name: StatusColor, text: string) => string = (_name, text) => text,
194
+ pr?: PullRequest | null,
126
195
  ): string {
127
196
  const separator = color("muted", " • ");
197
+ const head = [color("text", status.branch)];
198
+ if (pr) head.push(hyperlink(pr.url, color(PR_COLORS[pr.state], `#${pr.number}`)));
128
199
  if (status.changed === 0 && status.untracked === 0) {
129
- return color("text", status.branch) + separator + color("success", "clean");
200
+ return [...head, color("success", "clean")].join(separator);
130
201
  }
131
202
  return [
132
- color("text", status.branch),
203
+ ...head,
133
204
  color(status.changed ? "accent" : "muted", `${status.changed} changed`),
134
205
  color("muted", `${status.untracked} untracked`),
135
206
  `${color(status.added ? "success" : "muted", `+${status.added}`)} ${color(status.removed ? "error" : "muted", `−${status.removed}`)}`,
package/index.ts CHANGED
@@ -1,17 +1,43 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { formatGitStatus, readGitStatus } from "./core.ts";
2
+ import {
3
+ formatGitStatus,
4
+ readGitStatus,
5
+ readPullRequest,
6
+ type GitStatus,
7
+ type PullRequest,
8
+ } from "./core.ts";
3
9
 
4
10
  const STATUS_KEY = "git-status";
5
11
  const DEBOUNCE_MS = 150;
12
+ const SECOND = 1_000;
13
+ const MINUTE = 60 * SECOND;
14
+ // Same cadence as Claude Code's PR footer: slow down as the user goes idle, stop after an hour.
15
+ const PR_IDLE_STOP_MS = 60 * MINUTE;
16
+ const PR_SKIP_BRANCHES = new Set(["detached", "main", "master"]);
17
+ const PR_COMMAND = /\b(?:git\s+(?:push|checkout|switch)|gh\s+pr)\b/;
18
+
19
+ function idleInterval(idle: number) {
20
+ if (idle < 30 * SECOND) return 90 * SECOND;
21
+ if (idle < 5 * MINUTE) return 3 * MINUTE;
22
+ if (idle < 30 * MINUTE) return 10 * MINUTE;
23
+ return 30 * MINUTE;
24
+ }
25
+
26
+ /** Backoff after lookups that found no PR or failed. */
27
+ function missInterval(misses: number) {
28
+ return [90 * SECOND, 5 * MINUTE, 15 * MINUTE][misses] ?? 30 * MINUTE;
29
+ }
6
30
 
7
31
  export default function (pi: ExtensionAPI) {
8
32
  let stop: (() => void) | undefined;
9
- let requestRefresh: ((ctx: ExtensionContext) => void) | undefined;
33
+ let requestRefresh: ((ctx: ExtensionContext, options?: { pr?: boolean }) => void) | undefined;
34
+ let markActive: (() => void) | undefined;
10
35
 
11
36
  pi.on("session_start", (_event, ctx) => {
12
37
  stop?.();
13
38
  stop = undefined;
14
39
  requestRefresh = undefined;
40
+ markActive = undefined;
15
41
  if (ctx.mode !== "tui" || !ctx.hasUI) return;
16
42
 
17
43
  let context = ctx;
@@ -22,6 +48,91 @@ export default function (pi: ExtensionAPI) {
22
48
  let debounce: ReturnType<typeof setTimeout> | undefined;
23
49
  const controller = new AbortController();
24
50
 
51
+ let git: GitStatus | null = null;
52
+ let pr: PullRequest | null = null;
53
+ let prBranch: string | undefined;
54
+ let prUnavailable = false;
55
+ let prBusy = false;
56
+ let prPending = false;
57
+ let prForced = false;
58
+ let prTimer: ReturnType<typeof setTimeout> | undefined;
59
+ let lastPrFetch = 0;
60
+ let prMisses = 0;
61
+ let lastActivity = Date.now();
62
+
63
+ function render() {
64
+ const current = context;
65
+ const text = git
66
+ ? formatGitStatus(
67
+ git,
68
+ (name, value) => current.ui.theme.fg(name, value),
69
+ prBranch === git.branch ? pr : null,
70
+ )
71
+ : undefined;
72
+ if (text !== lastText) {
73
+ current.ui.setStatus(STATUS_KEY, text);
74
+ lastText = text;
75
+ }
76
+ }
77
+
78
+ function prEligible() {
79
+ return !disposed && !prUnavailable && git !== null && !PR_SKIP_BRANCHES.has(git.branch);
80
+ }
81
+
82
+ function schedulePr() {
83
+ if (prTimer) clearTimeout(prTimer);
84
+ prTimer = undefined;
85
+ if (!prEligible()) return;
86
+ const now = Date.now();
87
+ if (now - lastActivity >= PR_IDLE_STOP_MS) return;
88
+ const interval = Math.max(idleInterval(now - lastActivity), missInterval(prMisses));
89
+ prTimer = setTimeout(
90
+ () => {
91
+ prTimer = undefined;
92
+ if (Date.now() - lastActivity < PR_IDLE_STOP_MS) void fetchPr();
93
+ },
94
+ Math.max(0, lastPrFetch + interval - now),
95
+ );
96
+ prTimer.unref();
97
+ }
98
+
99
+ async function fetchPr() {
100
+ if (!prEligible()) return;
101
+ if (prBusy) {
102
+ prPending = true;
103
+ return;
104
+ }
105
+ if (prTimer) clearTimeout(prTimer);
106
+ prTimer = undefined;
107
+ prBusy = true;
108
+ const branch = git!.branch;
109
+ try {
110
+ const result = await readPullRequest(context.cwd, controller.signal).catch(
111
+ () => "error" as const,
112
+ );
113
+ if (disposed || git?.branch !== branch) return;
114
+ lastPrFetch = Date.now();
115
+ if (result === "unavailable") {
116
+ prUnavailable = true;
117
+ pr = null;
118
+ } else if (result === "error") {
119
+ prMisses++;
120
+ } else {
121
+ pr = result;
122
+ prMisses = result ? 0 : prMisses + 1;
123
+ }
124
+ render();
125
+ } finally {
126
+ prBusy = false;
127
+ if (prPending && !disposed) {
128
+ prPending = false;
129
+ void fetchPr();
130
+ } else {
131
+ schedulePr();
132
+ }
133
+ }
134
+ }
135
+
25
136
  async function refresh() {
26
137
  if (disposed) return;
27
138
  if (busy) {
@@ -29,16 +140,23 @@ export default function (pi: ExtensionAPI) {
29
140
  return;
30
141
  }
31
142
  busy = true;
32
- const current = context;
33
143
  try {
34
- const status = await readGitStatus(current.cwd, controller.signal);
144
+ const status = await readGitStatus(context.cwd, controller.signal);
35
145
  if (disposed) return;
36
- const text = status
37
- ? formatGitStatus(status, (name, value) => current.ui.theme.fg(name, value))
38
- : undefined;
39
- if (text !== lastText) {
40
- current.ui.setStatus(STATUS_KEY, text);
41
- lastText = text;
146
+ git = status;
147
+ let fetch = prForced;
148
+ prForced = false;
149
+ if (status && status.branch !== prBranch) {
150
+ prBranch = status.branch;
151
+ pr = null;
152
+ prMisses = 0;
153
+ fetch = true;
154
+ }
155
+ render();
156
+ if (fetch) void fetchPr();
157
+ else if (!status && prTimer) {
158
+ clearTimeout(prTimer);
159
+ prTimer = undefined;
42
160
  }
43
161
  } finally {
44
162
  busy = false;
@@ -49,8 +167,12 @@ export default function (pi: ExtensionAPI) {
49
167
  }
50
168
  }
51
169
 
52
- requestRefresh = (nextContext) => {
170
+ requestRefresh = (nextContext, options) => {
53
171
  context = nextContext;
172
+ if (options?.pr) {
173
+ prForced = true;
174
+ prMisses = 0;
175
+ }
54
176
  if (debounce) clearTimeout(debounce);
55
177
  debounce = setTimeout(() => {
56
178
  debounce = undefined;
@@ -59,28 +181,49 @@ export default function (pi: ExtensionAPI) {
59
181
  debounce.unref();
60
182
  };
61
183
 
184
+ markActive = () => {
185
+ const wasIdle = Date.now() - lastActivity >= PR_IDLE_STOP_MS;
186
+ lastActivity = Date.now();
187
+ // Polling stops after an hour idle; resume it, fetching right away if overdue.
188
+ if (wasIdle || (!prTimer && !prBusy)) schedulePr();
189
+ };
190
+
62
191
  stop = () => {
63
192
  if (disposed) return;
64
193
  disposed = true;
65
194
  if (debounce) clearTimeout(debounce);
195
+ if (prTimer) clearTimeout(prTimer);
66
196
  controller.abort();
67
197
  context.ui.setStatus(STATUS_KEY, undefined);
68
198
  };
69
199
  void refresh();
70
200
  });
71
201
 
72
- // Refresh after successful file mutations, not read-only tools or every shell call.
202
+ pi.on("input", () => {
203
+ markActive?.();
204
+ });
205
+
73
206
  pi.on("tool_result", (event, ctx) => {
74
- if (!event.isError && (event.toolName === "edit" || event.toolName === "write")) {
75
- requestRefresh?.(ctx);
207
+ markActive?.();
208
+ if (event.isError) return;
209
+ // Refresh after successful file mutations, not read-only tools or every shell call.
210
+ if (event.toolName === "edit" || event.toolName === "write") requestRefresh?.(ctx);
211
+ // Pushes, checkouts, and gh pr commands can create a PR or change its state.
212
+ const command = (event.input as { command?: unknown }).command;
213
+ if (event.toolName === "bash" && typeof command === "string" && PR_COMMAND.test(command)) {
214
+ requestRefresh?.(ctx, { pr: true });
76
215
  }
77
216
  });
78
217
  // Catch shell-made changes once the agent finishes.
79
- pi.on("agent_end", (_event, ctx) => requestRefresh?.(ctx));
218
+ pi.on("agent_end", (_event, ctx) => {
219
+ markActive?.();
220
+ requestRefresh?.(ctx);
221
+ });
80
222
 
81
223
  pi.on("session_shutdown", () => {
82
224
  stop?.();
83
225
  stop = undefined;
84
226
  requestRefresh = undefined;
227
+ markActive = undefined;
85
228
  });
86
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadjs/pi-git-status",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Live Git branch, changed files, and line counts in the Pi footer",
5
5
  "keywords": [
6
6
  "extension",