@henryqw/pi-footer 1.0.2 → 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 CHANGED
@@ -12,26 +12,43 @@ pi install npm:@henryqw/pi-footer
12
12
 
13
13
  ## Works with
14
14
 
15
- | Package | Why |
16
- | --- | --- |
17
- | [`@henryqw/pi-multi-codex`](https://pi.henry.wang/extensions/pi-multi-codex) | Improves. Adds active Codex subscription quota and reset status. |
18
- | [`@henryqw/pi-open-in`](https://pi.henry.wang/extensions/pi-open-in) | Improves. Adds `/open` and `/set-open-in` commands for editor configuration. |
19
- | [`@henryqw/pi-pr`](https://pi.henry.wang/extensions/pi-pr) | Improves. Adds current-branch pull-request status. |
15
+ | Package | Relationship | Purpose |
16
+ | --- | --- | --- |
17
+ | [`@henryqw/pi-multi-codex`](https://pi.henry.wang/extensions/pi-multi-codex) | Improves | Adds active Codex subscription quota and reset status. |
18
+ | [`@henryqw/pi-open-in`](https://pi.henry.wang/extensions/pi-open-in) | Improves | Adds `/open` and `/set-open-in` commands for editor configuration. |
19
+ | [`@henryqw/pi-pr`](https://pi.henry.wang/extensions/pi-pr) | Improves | Adds current-branch pull-request status. |
20
20
 
21
21
  ## Use
22
22
 
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 after the clickable checkout link. Linked-worktree branches drop the generated `worktree/` prefix.
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`.
@@ -42,12 +59,6 @@ Non-empty statuses from `@henryqw` extensions, currently Codex quota, occupy the
42
59
 
43
60
  Statuses from all other extensions, including Ponytail and `pi-rewind`, share the left side. They are sorted by key with producer text, spacing, colors, links, and glyphs preserved.
44
61
 
45
- ## State and storage
46
-
47
- Agent-work time counts each run from `agent_start` through the final idle `agent_settled`. It includes automatic retries and auto-compaction inside that run. It excludes blocking user-prompt waits and idle waits between runs.
48
-
49
- Standalone `/compact` is excluded because it runs outside the agent-run lifecycle and emits no `agent_start`. The cumulative total is persisted in the session through a `pi-footer:agent-work` custom entry after each finalized run. It is restored on session resume.
50
-
51
62
  ## Limits and recovery
52
63
 
53
64
  When the configured executable is `code` and Pi reports hyperlink support, the accent-colored checkout name links to the current path. The link opens a new window for `code -n` or `code --new-window`. A missing config silently uses `code`. Other executables and terminals with hyperlinks disabled render plain text.
@@ -1,4 +1,5 @@
1
- import { basename, dirname } from "node:path";
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() && finalizeActive()) pi.appendEntry(AGENT_TIME_ENTRY, activeMilliseconds);
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(requestRuntimeRender);
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 branch = data.getGitBranch()?.replace(/^worktree\//, "");
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 identityLine = prStatus ? `${identity}${checkoutLink} · ${prStatus}` : `${identity}${checkoutLink}`;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-footer",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Show concise repository, branch, and usage details in the Pi footer.",
5
5
  "keywords": [
6
6
  "pi-package",