@diegopetrucci/pi-extensions 0.1.38 → 0.1.40
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/README.md
CHANGED
|
@@ -9,6 +9,7 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
|
|
|
9
9
|
- [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
|
|
10
10
|
- [`context-inspector`](./extensions/context-inspector): Adds `/context`, a local self-contained HTML dashboard that breaks down where the current session context is going, with category overview, top offenders, and drilldown search.
|
|
11
11
|
- [`dirty-repo-guard`](./extensions/dirty-repo-guard): Prompts before new sessions, session switches, or forks when the current git repo has uncommitted changes.
|
|
12
|
+
- [`git-footer`](./extensions/git-footer): Standalone extension that adds TLH-style git dirty counts, ahead/behind, and optional PR number to pi's built-in footer status area.
|
|
12
13
|
- [`gnosis`](./extensions/gnosis): Exposes the `gn` repo-local knowledge base CLI as an agent tool for searching and recording durable project decisions, constraints, and intent.
|
|
13
14
|
- [`inline-bash`](./extensions/inline-bash): Expands `!{command}` snippets in user prompts by running them through bash before the prompt reaches the agent.
|
|
14
15
|
- [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache enabled by default under the OS user cache directory, toggleable with `/librarian-cache`, with cached repos expiring after 7 days of non-use.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.78.0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# git-footer
|
|
2
|
+
|
|
3
|
+
A TLH-style git status add-on for pi's built-in footer.
|
|
4
|
+
|
|
5
|
+
This package is standalone-only and is not auto-loaded by the `@diegopetrucci/pi-extensions` collection package.
|
|
6
|
+
|
|
7
|
+
It keeps pi's default footer intact and adds a compact git status segment through pi's extension status API:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
~/repo (main) • session-name
|
|
11
|
+
↑12k ↓3k 44.1%/200k model
|
|
12
|
+
+2 ~1 ?3 ↑1 • PR #123
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Git status indicators:
|
|
16
|
+
|
|
17
|
+
- `!N`: conflicted paths
|
|
18
|
+
- `+N`: staged paths
|
|
19
|
+
- `~N`: unstaged paths
|
|
20
|
+
- `?N`: untracked paths
|
|
21
|
+
- `↑N`: commits ahead of upstream
|
|
22
|
+
- `↓N`: commits behind upstream
|
|
23
|
+
|
|
24
|
+
The extension polls git status in the background and caches the latest snapshot. It also performs a best-effort `gh pr view` lookup for the current branch; if `gh` is unavailable or the branch has no PR, the PR segment is omitted.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:@diegopetrucci/pi-git-footer
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then reload pi:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
/reload
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- Does not replace pi's built-in footer.
|
|
41
|
+
- Uses `ctx.ui.setStatus()`, so pi renders the git summary with other extension statuses.
|
|
42
|
+
- The current pi extension API does not support literally appending text inside the built-in footer's first `cwd (branch)` line without replacing the footer.
|
|
43
|
+
- Git and GitHub CLI lookups run on a short background interval with timeouts.
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
type GitStatusSnapshot = {
|
|
5
|
+
branch?: string;
|
|
6
|
+
staged: number;
|
|
7
|
+
unstaged: number;
|
|
8
|
+
untracked: number;
|
|
9
|
+
conflict: number;
|
|
10
|
+
ahead: number;
|
|
11
|
+
behind: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type PullRequestSnapshot = {
|
|
15
|
+
number?: number | string;
|
|
16
|
+
state?: string;
|
|
17
|
+
isDraft?: boolean;
|
|
18
|
+
url?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type CommandResult = {
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
exitCode: number | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type CommandRunner = (
|
|
29
|
+
command: string,
|
|
30
|
+
args: readonly string[],
|
|
31
|
+
options: { cwd: string; signal: AbortSignal },
|
|
32
|
+
) => Promise<CommandResult>;
|
|
33
|
+
|
|
34
|
+
type TimerHandle = unknown;
|
|
35
|
+
|
|
36
|
+
type Clock = {
|
|
37
|
+
setInterval(callback: () => void, ms: number): TimerHandle;
|
|
38
|
+
clearInterval(handle: TimerHandle): void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type GitFooterCacheOptions = {
|
|
42
|
+
cwd: () => string;
|
|
43
|
+
runner?: CommandRunner;
|
|
44
|
+
clock?: Clock;
|
|
45
|
+
refreshIntervalMs?: number;
|
|
46
|
+
gitTimeoutMs?: number;
|
|
47
|
+
ghTimeoutMs?: number;
|
|
48
|
+
onChange?: () => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const BRANCH_HEAD_PREFIX = "# branch.head ";
|
|
52
|
+
const BRANCH_AB_PREFIX = "# branch.ab ";
|
|
53
|
+
const STATUS_KEY = "git-footer";
|
|
54
|
+
const STATUS_SEPARATOR = " • ";
|
|
55
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 8_000;
|
|
56
|
+
const DEFAULT_GIT_TIMEOUT_MS = 1_500;
|
|
57
|
+
const DEFAULT_GH_TIMEOUT_MS = 3_000;
|
|
58
|
+
const GIT_STATUS_ARGS = ["--no-optional-locks", "status", "--porcelain=v2", "--branch"] as const;
|
|
59
|
+
const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,state,isDraft,url,title"] as const;
|
|
60
|
+
|
|
61
|
+
function createEmptyGitStatus(): GitStatusSnapshot {
|
|
62
|
+
return {
|
|
63
|
+
branch: undefined,
|
|
64
|
+
staged: 0,
|
|
65
|
+
unstaged: 0,
|
|
66
|
+
untracked: 0,
|
|
67
|
+
conflict: 0,
|
|
68
|
+
ahead: 0,
|
|
69
|
+
behind: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function positiveCount(value: number): number {
|
|
74
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function addTrackedStatusCounts(status: GitStatusSnapshot, xy: string): void {
|
|
78
|
+
if (xy.length !== 2) return;
|
|
79
|
+
if (xy[0] !== ".") status.staged += 1;
|
|
80
|
+
if (xy[1] !== ".") status.unstaged += 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseBranchAheadBehind(line: string, status: GitStatusSnapshot): void {
|
|
84
|
+
const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
|
|
85
|
+
if (!match) return;
|
|
86
|
+
status.ahead = Number.parseInt(match[1]!, 10);
|
|
87
|
+
status.behind = Number.parseInt(match[2]!, 10);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeBranchHead(value: string): string {
|
|
91
|
+
const branch = value.trim();
|
|
92
|
+
return branch === "(detached)" ? "detached" : branch;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseGitStatusPorcelainV2(output: string): GitStatusSnapshot {
|
|
96
|
+
const status = createEmptyGitStatus();
|
|
97
|
+
|
|
98
|
+
for (const rawLine of output.split("\n")) {
|
|
99
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
|
|
102
|
+
if (line.startsWith(BRANCH_HEAD_PREFIX)) {
|
|
103
|
+
status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (line.startsWith(BRANCH_AB_PREFIX)) {
|
|
108
|
+
parseBranchAheadBehind(line, status);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
113
|
+
addTrackedStatusCounts(status, line.slice(2, 4));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (line.startsWith("u ")) {
|
|
118
|
+
status.conflict += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (line.startsWith("? ")) status.untracked += 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return status;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatGitStatusFooterSegment(status: GitStatusSnapshot | undefined): string | undefined {
|
|
129
|
+
if (!status) return undefined;
|
|
130
|
+
|
|
131
|
+
const parts: string[] = [];
|
|
132
|
+
const indicators: Array<[string, number]> = [
|
|
133
|
+
["!", positiveCount(status.conflict)],
|
|
134
|
+
["+", positiveCount(status.staged)],
|
|
135
|
+
["~", positiveCount(status.unstaged)],
|
|
136
|
+
["?", positiveCount(status.untracked)],
|
|
137
|
+
["↑", positiveCount(status.ahead)],
|
|
138
|
+
["↓", positiveCount(status.behind)],
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
for (const [prefix, count] of indicators) {
|
|
142
|
+
if (count > 0) parts.push(`${prefix}${count}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function formatPullRequestFooterSegment(pullRequest: PullRequestSnapshot | undefined): string | undefined {
|
|
149
|
+
const value = pullRequest?.number;
|
|
150
|
+
if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
|
|
151
|
+
if (typeof value === "string") {
|
|
152
|
+
const trimmed = value.trim();
|
|
153
|
+
if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatGitFooterStatus(
|
|
159
|
+
status: GitStatusSnapshot | undefined,
|
|
160
|
+
pullRequest: PullRequestSnapshot | undefined,
|
|
161
|
+
): string | undefined {
|
|
162
|
+
const parts = [
|
|
163
|
+
formatGitStatusFooterSegment(status),
|
|
164
|
+
formatPullRequestFooterSegment(pullRequest),
|
|
165
|
+
].filter((part): part is string => !!part);
|
|
166
|
+
return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
|
|
170
|
+
const trimmed = stdout.trim();
|
|
171
|
+
if (!trimmed) return undefined;
|
|
172
|
+
let parsed: unknown;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(trimmed);
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
179
|
+
|
|
180
|
+
const record = parsed as Record<string, unknown>;
|
|
181
|
+
const snapshot: PullRequestSnapshot = {};
|
|
182
|
+
if (typeof record.number === "number" || typeof record.number === "string") {
|
|
183
|
+
snapshot.number = record.number;
|
|
184
|
+
}
|
|
185
|
+
if (typeof record.state === "string") snapshot.state = record.state;
|
|
186
|
+
if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
|
|
187
|
+
if (typeof record.url === "string") snapshot.url = record.url;
|
|
188
|
+
if (typeof record.title === "string") snapshot.title = record.title;
|
|
189
|
+
return snapshot;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function gitStatusSnapshotsEqual(
|
|
193
|
+
left: GitStatusSnapshot | undefined,
|
|
194
|
+
right: GitStatusSnapshot | undefined,
|
|
195
|
+
): boolean {
|
|
196
|
+
return (
|
|
197
|
+
left?.branch === right?.branch
|
|
198
|
+
&& left?.staged === right?.staged
|
|
199
|
+
&& left?.unstaged === right?.unstaged
|
|
200
|
+
&& left?.untracked === right?.untracked
|
|
201
|
+
&& left?.conflict === right?.conflict
|
|
202
|
+
&& left?.ahead === right?.ahead
|
|
203
|
+
&& left?.behind === right?.behind
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function pullRequestSnapshotsEqual(
|
|
208
|
+
left: PullRequestSnapshot | undefined,
|
|
209
|
+
right: PullRequestSnapshot | undefined,
|
|
210
|
+
): boolean {
|
|
211
|
+
return (
|
|
212
|
+
left?.number === right?.number
|
|
213
|
+
&& left?.state === right?.state
|
|
214
|
+
&& left?.isDraft === right?.isDraft
|
|
215
|
+
&& left?.url === right?.url
|
|
216
|
+
&& left?.title === right?.title
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function defaultRunner(
|
|
221
|
+
command: string,
|
|
222
|
+
args: readonly string[],
|
|
223
|
+
options: { cwd: string; signal: AbortSignal },
|
|
224
|
+
): Promise<CommandResult> {
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
let child;
|
|
227
|
+
try {
|
|
228
|
+
child = spawn(command, [...args], {
|
|
229
|
+
cwd: options.cwd,
|
|
230
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
231
|
+
windowsHide: true,
|
|
232
|
+
});
|
|
233
|
+
} catch (error) {
|
|
234
|
+
reject(error);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let stdout = "";
|
|
239
|
+
let stderr = "";
|
|
240
|
+
let settled = false;
|
|
241
|
+
|
|
242
|
+
const finish = (result: CommandResult | Error) => {
|
|
243
|
+
if (settled) return;
|
|
244
|
+
settled = true;
|
|
245
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
246
|
+
if (result instanceof Error) reject(result);
|
|
247
|
+
else resolve(result);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const onAbort = () => {
|
|
251
|
+
try {
|
|
252
|
+
child.kill("SIGTERM");
|
|
253
|
+
} catch {
|
|
254
|
+
// Ignore: process may already be gone.
|
|
255
|
+
}
|
|
256
|
+
finish(new Error("aborted"));
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
260
|
+
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
261
|
+
});
|
|
262
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
263
|
+
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
264
|
+
});
|
|
265
|
+
child.on("error", finish);
|
|
266
|
+
child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
|
|
267
|
+
|
|
268
|
+
if (options.signal.aborted) {
|
|
269
|
+
onAbort();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function defaultClock(): Clock {
|
|
277
|
+
return {
|
|
278
|
+
setInterval(callback, ms) {
|
|
279
|
+
const handle = setInterval(callback, ms);
|
|
280
|
+
(handle as { unref?: () => void }).unref?.();
|
|
281
|
+
return handle;
|
|
282
|
+
},
|
|
283
|
+
clearInterval(handle) {
|
|
284
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
class GitFooterCache {
|
|
290
|
+
private readonly cwd: () => string;
|
|
291
|
+
private readonly runner: CommandRunner;
|
|
292
|
+
private readonly clock: Clock;
|
|
293
|
+
private readonly refreshIntervalMs: number;
|
|
294
|
+
private readonly gitTimeoutMs: number;
|
|
295
|
+
private readonly ghTimeoutMs: number;
|
|
296
|
+
private readonly onChange: (() => void) | undefined;
|
|
297
|
+
|
|
298
|
+
private intervalHandle: TimerHandle | undefined;
|
|
299
|
+
private readonly inflightControllers = new Set<AbortController>();
|
|
300
|
+
private disposed = false;
|
|
301
|
+
private refreshInFlight: Promise<void> | undefined;
|
|
302
|
+
private statusSnapshot: GitStatusSnapshot | undefined;
|
|
303
|
+
private pullRequestSnapshot: PullRequestSnapshot | undefined;
|
|
304
|
+
private lastSeenBranch: string | undefined;
|
|
305
|
+
|
|
306
|
+
constructor(options: GitFooterCacheOptions) {
|
|
307
|
+
this.cwd = options.cwd;
|
|
308
|
+
this.runner = options.runner ?? defaultRunner;
|
|
309
|
+
this.clock = options.clock ?? defaultClock();
|
|
310
|
+
this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
|
|
311
|
+
this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
|
312
|
+
this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_GH_TIMEOUT_MS;
|
|
313
|
+
this.onChange = options.onChange;
|
|
314
|
+
|
|
315
|
+
this.intervalHandle = this.clock.setInterval(() => {
|
|
316
|
+
void this.refresh();
|
|
317
|
+
}, this.refreshIntervalMs);
|
|
318
|
+
void this.refresh();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getStatusSnapshot(): GitStatusSnapshot | undefined {
|
|
322
|
+
return this.statusSnapshot;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
getPullRequestSnapshot(): PullRequestSnapshot | undefined {
|
|
326
|
+
return this.pullRequestSnapshot;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
refresh(): Promise<void> {
|
|
330
|
+
if (this.disposed) return Promise.resolve();
|
|
331
|
+
if (this.refreshInFlight) return this.refreshInFlight;
|
|
332
|
+
const run = this.runRefresh()
|
|
333
|
+
.finally(() => {
|
|
334
|
+
this.refreshInFlight = undefined;
|
|
335
|
+
})
|
|
336
|
+
.catch(() => undefined);
|
|
337
|
+
this.refreshInFlight = run;
|
|
338
|
+
return run;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private async runRefresh(): Promise<void> {
|
|
342
|
+
const previousStatusSnapshot = this.statusSnapshot;
|
|
343
|
+
const previousPullRequestSnapshot = this.pullRequestSnapshot;
|
|
344
|
+
|
|
345
|
+
const result = await this.fetchGitStatus();
|
|
346
|
+
if (this.disposed) return;
|
|
347
|
+
if (result.kind === "transient") return;
|
|
348
|
+
if (result.kind === "not-a-repo") {
|
|
349
|
+
this.statusSnapshot = undefined;
|
|
350
|
+
this.pullRequestSnapshot = undefined;
|
|
351
|
+
this.lastSeenBranch = undefined;
|
|
352
|
+
this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const status = result.status;
|
|
357
|
+
this.statusSnapshot = status;
|
|
358
|
+
|
|
359
|
+
const branch = typeof status.branch === "string" ? status.branch : undefined;
|
|
360
|
+
const isValidBranch = !!branch && branch !== "detached";
|
|
361
|
+
const branchChanged = branch !== this.lastSeenBranch;
|
|
362
|
+
|
|
363
|
+
if (branchChanged) this.pullRequestSnapshot = undefined;
|
|
364
|
+
this.lastSeenBranch = branch;
|
|
365
|
+
|
|
366
|
+
if (!isValidBranch) {
|
|
367
|
+
this.pullRequestSnapshot = undefined;
|
|
368
|
+
this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const pr = await this.fetchPullRequest();
|
|
373
|
+
if (this.disposed) return;
|
|
374
|
+
if (pr !== undefined) this.pullRequestSnapshot = pr;
|
|
375
|
+
else if (branchChanged) this.pullRequestSnapshot = undefined;
|
|
376
|
+
|
|
377
|
+
this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private emitChangeIfSnapshotsChanged(
|
|
381
|
+
previousStatusSnapshot: GitStatusSnapshot | undefined,
|
|
382
|
+
previousPullRequestSnapshot: PullRequestSnapshot | undefined,
|
|
383
|
+
): void {
|
|
384
|
+
if (this.disposed) return;
|
|
385
|
+
if (
|
|
386
|
+
gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot)
|
|
387
|
+
&& pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
|
|
388
|
+
) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
this.onChange?.();
|
|
393
|
+
} catch {
|
|
394
|
+
// Rendering hooks should not break refreshes.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private async fetchGitStatus(): Promise<
|
|
399
|
+
| { kind: "ok"; status: GitStatusSnapshot }
|
|
400
|
+
| { kind: "not-a-repo" }
|
|
401
|
+
| { kind: "transient" }
|
|
402
|
+
> {
|
|
403
|
+
const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
|
|
404
|
+
if (!result) return { kind: "transient" };
|
|
405
|
+
if (result.exitCode !== 0) return { kind: "not-a-repo" };
|
|
406
|
+
return { kind: "ok", status: parseGitStatusPorcelainV2(result.stdout) };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private async fetchPullRequest(): Promise<PullRequestSnapshot | undefined> {
|
|
410
|
+
const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
|
|
411
|
+
if (!result || result.exitCode !== 0) return undefined;
|
|
412
|
+
return parsePullRequestJson(result.stdout);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async runCommandSafely(
|
|
416
|
+
command: string,
|
|
417
|
+
args: readonly string[],
|
|
418
|
+
timeoutMs: number,
|
|
419
|
+
): Promise<CommandResult | undefined> {
|
|
420
|
+
if (this.disposed) return undefined;
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
this.inflightControllers.add(controller);
|
|
423
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
424
|
+
try {
|
|
425
|
+
return await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal });
|
|
426
|
+
} catch {
|
|
427
|
+
return undefined;
|
|
428
|
+
} finally {
|
|
429
|
+
clearTimeout(timeoutId);
|
|
430
|
+
this.inflightControllers.delete(controller);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
dispose(): void {
|
|
435
|
+
if (this.disposed) return;
|
|
436
|
+
this.disposed = true;
|
|
437
|
+
if (this.intervalHandle !== undefined) {
|
|
438
|
+
this.clock.clearInterval(this.intervalHandle);
|
|
439
|
+
this.intervalHandle = undefined;
|
|
440
|
+
}
|
|
441
|
+
for (const controller of this.inflightControllers) controller.abort();
|
|
442
|
+
this.inflightControllers.clear();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export default function (pi: ExtensionAPI) {
|
|
447
|
+
let cache: GitFooterCache | undefined;
|
|
448
|
+
|
|
449
|
+
function disposeCache(): void {
|
|
450
|
+
cache?.dispose();
|
|
451
|
+
cache = undefined;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
pi.on("session_start", (_event, ctx) => {
|
|
455
|
+
disposeCache();
|
|
456
|
+
|
|
457
|
+
const updateStatus = () => {
|
|
458
|
+
const text = formatGitFooterStatus(
|
|
459
|
+
cache?.getStatusSnapshot(),
|
|
460
|
+
cache?.getPullRequestSnapshot(),
|
|
461
|
+
);
|
|
462
|
+
ctx.ui.setStatus(STATUS_KEY, text ? ctx.ui.theme.fg("dim", text) : undefined);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
cache = new GitFooterCache({
|
|
466
|
+
cwd: () => ctx.cwd,
|
|
467
|
+
onChange: updateStatus,
|
|
468
|
+
});
|
|
469
|
+
updateStatus();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
pi.on("turn_end", () => {
|
|
473
|
+
void cache?.refresh();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
477
|
+
disposeCache();
|
|
478
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diegopetrucci/pi-git-footer",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A TLH-style git status add-on for pi's built-in footer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"terminal",
|
|
9
|
+
"footer",
|
|
10
|
+
"git"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/diegopetrucci/pi-extensions.git",
|
|
16
|
+
"directory": "extensions/git-footer"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.ts",
|
|
20
|
+
"README.md",
|
|
21
|
+
".pi-fleet-tested-version"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"index.ts"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.40",
|
|
4
4
|
"description": "A collection of pi extensions for context management, workflow audits, review-comment triage, notifications, brrr push alerts, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, and model/provider helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|