@henryqw/pi-footer 1.0.3 → 1.1.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/README.md +19 -2
- package/extensions/footer.ts +126 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,15 +23,32 @@ pi install npm:@henryqw/pi-footer
|
|
|
23
23
|
The footer shows checkout, usage, model, thinking, and extension statuses.
|
|
24
24
|
|
|
25
25
|
```text
|
|
26
|
-
pi-harness · clear-field-f8d2 · PR #123 · approved Codex #1 · 50% · 7d 1d 1h 22m
|
|
26
|
+
pi-harness · clear-field-f8d2 [+2 ~3 ?1 ↑2] · PR #123 · approved Codex #1 · 50% · 7d 1d 1h 22m
|
|
27
27
|
↑ 12.4k · ↓ 2.1k · ↺ 84.3% · ⚡ 87.4 t/s · $ 0.127 · ◔ 36.8% gpt-5.6-luna • high
|
|
28
28
|
● 🐴 ponytail: ⚡ FULL ◷ 12m 34s
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
- The first line shows the repository, branch, and `pi-pr` pull request status
|
|
31
|
+
- The first line shows the repository, branch, Git state, and `pi-pr` pull request status. Linked-worktree branches drop the generated `worktree/` prefix.
|
|
32
32
|
- The second line shows cumulative input tokens, output tokens, latest cache-hit rate, and tokens per second for the most recent assistant response. It also shows estimated cost and context usage. Totals include reported tool usage and finished `pi-subagent` background workflows. The active model and thinking level are right-aligned.
|
|
33
33
|
- The third line shows other extension statuses on the left and cumulative agent-work time on the right, beneath the active model.
|
|
34
34
|
|
|
35
|
+
Git badges appear only when action is needed:
|
|
36
|
+
|
|
37
|
+
| Badge | Meaning |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `+2` | Two staged paths |
|
|
40
|
+
| `~3` | Three unstaged tracked paths |
|
|
41
|
+
| `?N` | `N` untracked porcelain status entries |
|
|
42
|
+
| `!1` | One unresolved path |
|
|
43
|
+
| `↑2` | Two commits ahead of the upstream branch |
|
|
44
|
+
| `↓1` | One commit behind the upstream branch |
|
|
45
|
+
|
|
46
|
+
`?N` counts porcelain status entries. In `--untracked-files=normal` mode, Git may collapse a whole untracked directory to one entry.
|
|
47
|
+
|
|
48
|
+
Active operations appear first, such as `REBASE 3/7`, `MERGING`, or `CHERRY-PICKING`. Detached HEAD uses the short commit form `@a1b2c3d`.
|
|
49
|
+
|
|
50
|
+
The extension reads local Git data with `git status --porcelain=v2 --branch --untracked-files=normal`. It refreshes after each agent run and branch change. It does not make an LLM call or fetch a remote. Ahead and behind counts use the last fetched upstream state.
|
|
51
|
+
|
|
35
52
|
Unavailable values render as `—` without a misleading percent sign.
|
|
36
53
|
|
|
37
54
|
`off` uses the same dim grey as the model name. Active levels use an ANSI-256 gradient: green `minimal`, yellow-green `low`, lime `medium`, yellow `high`, orange `xhigh`, and red `max`.
|
package/extensions/footer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { getCapabilities, hyperlink, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
5
|
import { configuredOpenUri } from "@henryqw/pi-open-in/open-uri";
|
|
@@ -16,6 +17,9 @@ const AGENT_TIME_ENTRY = "pi-footer:agent-work";
|
|
|
16
17
|
const SUBAGENT_BACKGROUND_RESULT = "subagent-background-result";
|
|
17
18
|
|
|
18
19
|
type CountedUsage = { input: number; output: number; cost: { total: number } };
|
|
20
|
+
type GitSummary = { badges: string; detachedOid?: string };
|
|
21
|
+
|
|
22
|
+
const EMPTY_GIT_SUMMARY: GitSummary = { badges: "" };
|
|
19
23
|
|
|
20
24
|
function isNonNegativeNumber(value: unknown): value is number {
|
|
21
25
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
@@ -51,6 +55,95 @@ function sanitizeStatus(text: string): string {
|
|
|
51
55
|
return text.replace(/[\r\n]+/g, " ").trim();
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
function summarizeGitStatus(output: string, operation?: string): GitSummary {
|
|
59
|
+
let staged = 0;
|
|
60
|
+
let unstaged = 0;
|
|
61
|
+
let untracked = 0;
|
|
62
|
+
let conflicts = 0;
|
|
63
|
+
let ahead = 0;
|
|
64
|
+
let behind = 0;
|
|
65
|
+
let detachedOid: string | undefined;
|
|
66
|
+
|
|
67
|
+
for (const line of output.split(/\r?\n/)) {
|
|
68
|
+
if (line.startsWith("# branch.oid ")) {
|
|
69
|
+
const oid = line.slice(13).trim();
|
|
70
|
+
if (oid !== "(initial)") detachedOid = oid.slice(0, 7);
|
|
71
|
+
} else if (line.startsWith("# branch.ab ")) {
|
|
72
|
+
const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
|
|
73
|
+
if (match) {
|
|
74
|
+
ahead = Number(match[1]);
|
|
75
|
+
behind = Number(match[2]);
|
|
76
|
+
}
|
|
77
|
+
} else if (line.startsWith("? ")) {
|
|
78
|
+
untracked++;
|
|
79
|
+
} else if (line.startsWith("u ")) {
|
|
80
|
+
conflicts++;
|
|
81
|
+
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
82
|
+
const xy = line.slice(2, 4);
|
|
83
|
+
if (xy[0] !== ".") staged++;
|
|
84
|
+
if (xy[1] !== ".") unstaged++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const badges = [
|
|
89
|
+
operation,
|
|
90
|
+
conflicts ? `!${conflicts}` : undefined,
|
|
91
|
+
staged ? `+${staged}` : undefined,
|
|
92
|
+
unstaged ? `~${unstaged}` : undefined,
|
|
93
|
+
untracked ? `?${untracked}` : undefined,
|
|
94
|
+
ahead ? `↑${ahead}` : undefined,
|
|
95
|
+
behind ? `↓${behind}` : undefined,
|
|
96
|
+
].filter(Boolean).join(" ");
|
|
97
|
+
return { badges: badges ? `[${badges}]` : "", detachedOid };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readOptional(path: string): Promise<string | undefined> {
|
|
101
|
+
try {
|
|
102
|
+
return await readFile(path, "utf8");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function operationProgress(current: string | undefined, total: string | undefined): string {
|
|
110
|
+
const currentStep = Number.parseInt(current ?? "", 10);
|
|
111
|
+
const totalSteps = Number.parseInt(total ?? "", 10);
|
|
112
|
+
return currentStep > 0 && totalSteps > 0 ? ` ${currentStep}/${totalSteps}` : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readGitOperation(gitDir: string): Promise<string | undefined> {
|
|
116
|
+
const [
|
|
117
|
+
rebaseMergeCurrent,
|
|
118
|
+
rebaseMergeTotal,
|
|
119
|
+
rebaseApplyCurrent,
|
|
120
|
+
rebaseApplyTotal,
|
|
121
|
+
applying,
|
|
122
|
+
mergeHead,
|
|
123
|
+
cherryPickHead,
|
|
124
|
+
revertHead,
|
|
125
|
+
bisectLog,
|
|
126
|
+
] = await Promise.all([
|
|
127
|
+
readOptional(join(gitDir, "rebase-merge", "msgnum")),
|
|
128
|
+
readOptional(join(gitDir, "rebase-merge", "end")),
|
|
129
|
+
readOptional(join(gitDir, "rebase-apply", "next")),
|
|
130
|
+
readOptional(join(gitDir, "rebase-apply", "last")),
|
|
131
|
+
readOptional(join(gitDir, "rebase-apply", "applying")),
|
|
132
|
+
readOptional(join(gitDir, "MERGE_HEAD")),
|
|
133
|
+
readOptional(join(gitDir, "CHERRY_PICK_HEAD")),
|
|
134
|
+
readOptional(join(gitDir, "REVERT_HEAD")),
|
|
135
|
+
readOptional(join(gitDir, "BISECT_LOG")),
|
|
136
|
+
]);
|
|
137
|
+
if (rebaseMergeCurrent !== undefined) return `REBASE${operationProgress(rebaseMergeCurrent, rebaseMergeTotal)}`;
|
|
138
|
+
if (rebaseApplyCurrent !== undefined) {
|
|
139
|
+
return `${applying === undefined ? "REBASE" : "AM"}${operationProgress(rebaseApplyCurrent, rebaseApplyTotal)}`;
|
|
140
|
+
}
|
|
141
|
+
if (mergeHead !== undefined) return "MERGING";
|
|
142
|
+
if (cherryPickHead !== undefined) return "CHERRY-PICKING";
|
|
143
|
+
if (revertHead !== undefined) return "REVERTING";
|
|
144
|
+
if (bisectLog !== undefined) return "BISECTING";
|
|
145
|
+
}
|
|
146
|
+
|
|
54
147
|
function align(left: string, right: string, width: number, ellipsis: string): string {
|
|
55
148
|
const available = width - visibleWidth(left) - 2;
|
|
56
149
|
if (available <= 0) return truncateToWidth(left, width, ellipsis);
|
|
@@ -80,6 +173,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
80
173
|
let promptPaused = false;
|
|
81
174
|
let runtimeTimer: ReturnType<typeof setInterval> | undefined;
|
|
82
175
|
let requestRuntimeRender: (() => void) | undefined;
|
|
176
|
+
let refreshGitStatus: (() => Promise<void>) | undefined;
|
|
83
177
|
const stopRuntimeTimer = () => {
|
|
84
178
|
if (runtimeTimer === undefined) return;
|
|
85
179
|
clearInterval(runtimeTimer);
|
|
@@ -126,13 +220,16 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
126
220
|
pi.on("ui_prompt_end", () => {
|
|
127
221
|
resumeActive();
|
|
128
222
|
});
|
|
129
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
130
|
-
if (ctx.isIdle()
|
|
223
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
224
|
+
if (!ctx.isIdle()) return;
|
|
225
|
+
if (finalizeActive()) pi.appendEntry(AGENT_TIME_ENTRY, activeMilliseconds);
|
|
226
|
+
await refreshGitStatus?.();
|
|
131
227
|
});
|
|
132
228
|
pi.on("session_shutdown", () => {
|
|
133
229
|
stopRuntimeTimer();
|
|
134
230
|
activeStartedAt = undefined;
|
|
135
231
|
promptPaused = false;
|
|
232
|
+
refreshGitStatus = undefined;
|
|
136
233
|
});
|
|
137
234
|
|
|
138
235
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -140,6 +237,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
140
237
|
activeStartedAt = undefined;
|
|
141
238
|
promptPaused = false;
|
|
142
239
|
requestRuntimeRender = undefined;
|
|
240
|
+
refreshGitStatus = undefined;
|
|
143
241
|
// Latest valid entry wins; stored data is untrusted.
|
|
144
242
|
activeMilliseconds = 0;
|
|
145
243
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
@@ -151,13 +249,28 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
151
249
|
|
|
152
250
|
const git = await pi.exec(
|
|
153
251
|
"git",
|
|
154
|
-
["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"],
|
|
252
|
+
["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir", "--git-dir"],
|
|
155
253
|
{ cwd: ctx.cwd },
|
|
156
254
|
);
|
|
157
|
-
const [root, commonDir] = git.stdout.trim().split(/\r?\n/);
|
|
255
|
+
const [root, commonDir, gitDir] = git.stdout.trim().split(/\r?\n/);
|
|
158
256
|
const rootName = basename(root || ctx.cwd);
|
|
159
257
|
const commonName = commonDir && basename(commonDir) === ".git" ? basename(dirname(commonDir)) : undefined;
|
|
160
258
|
const repo = git.code === 0 ? commonName && commonName !== rootName ? commonName : rootName : basename(ctx.cwd);
|
|
259
|
+
let gitSummary = EMPTY_GIT_SUMMARY;
|
|
260
|
+
let gitRefreshGeneration = 0;
|
|
261
|
+
if (git.code === 0 && gitDir) {
|
|
262
|
+
refreshGitStatus = async () => {
|
|
263
|
+
const generation = ++gitRefreshGeneration;
|
|
264
|
+
const [status, operation] = await Promise.all([
|
|
265
|
+
pi.exec("git", ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], { cwd: ctx.cwd }),
|
|
266
|
+
readGitOperation(gitDir),
|
|
267
|
+
]);
|
|
268
|
+
if (generation !== gitRefreshGeneration) return;
|
|
269
|
+
gitSummary = status.code === 0 ? summarizeGitStatus(status.stdout, operation) : EMPTY_GIT_SUMMARY;
|
|
270
|
+
requestRuntimeRender?.();
|
|
271
|
+
};
|
|
272
|
+
await refreshGitStatus();
|
|
273
|
+
}
|
|
161
274
|
|
|
162
275
|
let tps: number | undefined;
|
|
163
276
|
let assistantStartedAt: number | undefined;
|
|
@@ -208,7 +321,10 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
208
321
|
|
|
209
322
|
ctx.ui.setFooter((tui, theme, data) => {
|
|
210
323
|
requestRuntimeRender = () => tui.requestRender();
|
|
211
|
-
const unsubscribe = data.onBranchChange(
|
|
324
|
+
const unsubscribe = data.onBranchChange(() => {
|
|
325
|
+
void refreshGitStatus?.();
|
|
326
|
+
requestRuntimeRender?.();
|
|
327
|
+
});
|
|
212
328
|
return {
|
|
213
329
|
dispose() {
|
|
214
330
|
unsubscribe();
|
|
@@ -224,7 +340,8 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
224
340
|
computeUsage();
|
|
225
341
|
}
|
|
226
342
|
|
|
227
|
-
const
|
|
343
|
+
const reportedBranch = data.getGitBranch()?.replace(/^worktree\//, "");
|
|
344
|
+
const branch = reportedBranch === "detached" && gitSummary.detachedOid ? `@${gitSummary.detachedOid}` : reportedBranch;
|
|
228
345
|
const context = ctx.getContextUsage()?.percent;
|
|
229
346
|
const openUri = configuredOpenUri(ctx.cwd);
|
|
230
347
|
const extensionStatuses = data.getExtensionStatuses();
|
|
@@ -262,7 +379,8 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
262
379
|
const checkoutLink = openUri && getCapabilities().hyperlinks
|
|
263
380
|
? hyperlink(theme.fg("accent", checkout), openUri)
|
|
264
381
|
: theme.fg("dim", checkout);
|
|
265
|
-
const
|
|
382
|
+
const checkoutStatus = gitSummary.badges ? `${checkoutLink} ${theme.fg("dim", gitSummary.badges)}` : checkoutLink;
|
|
383
|
+
const identityLine = prStatus ? `${identity}${checkoutStatus} · ${prStatus}` : `${identity}${checkoutStatus}`;
|
|
266
384
|
const firstLine = henryStatuses.length ? align(identityLine, henryStatuses.join(" "), width, ellipsis) : identityLine;
|
|
267
385
|
const lines = [
|
|
268
386
|
firstLine,
|