pi-minimal-footer 0.4.2 → 0.5.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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-07
11
+
12
+ ### Added
13
+
14
+ - Show Git upstream divergence next to the branch, for example ` main ↑2 ↓1`; `↑` is local commits to push and `↓` is upstream commits to pull
15
+
16
+ ### Changed
17
+
18
+ - Query Git divergence asynchronously and omit it safely when the repository has no upstream or Git is unavailable
19
+
10
20
  ## [0.4.2] - 2026-10-07
11
21
 
12
22
  ### Fixed
package/README.md CHANGED
@@ -14,7 +14,7 @@ An opinionated, compact one-line footer for [Pi](https://github.com/earendil-wor
14
14
  **Left**
15
15
 
16
16
  - Working directory: `~/git/project`
17
- - Git branch: ` main`
17
+ - Git branch and upstream divergence: ` main ↑2 ↓1` (`↑` to push, `↓` to pull); counts appear when an upstream is configured
18
18
 
19
19
  **Right**
20
20
 
@@ -59,7 +59,8 @@ Edit `minFooter` in Pi's agent `settings.json` (normally `~/.pi/agent/settings.j
59
59
  ```
60
60
 
61
61
  - `showSkills` shows **extension status text**, not installed skills. The footer does not add the example statuses itself.
62
- - Set `powerlineSeparator` to `false` for plain spaces instead of `` / `` if your font lacks those glyphs. The git icon `` also needs a compatible font; hide it with `showGitBranch: false` if needed.
62
+ - Set `powerlineSeparator` to `false` for plain spaces instead of `` / `` if your font lacks those glyphs. The git icon `` and sync arrows also need compatible glyphs; hide Git data with `showGitBranch: false` if needed.
63
+ - Git sync counts are omitted for branches without an upstream, detached HEADs, and non-Git directories.
63
64
  - `maxUsageBarCells` caps the Braille bar length (excluding brackets and reset time). Use a positive integer, e.g. `4` for a compact bar; invalid values fall back to `10`.
64
65
 
65
66
  ## Reading the footer
package/VISION.md CHANGED
@@ -8,11 +8,12 @@ The [README](README.md) is the user manual. This document records current design
8
8
 
9
9
  ## Layout contract
10
10
 
11
- - Directory and git branch form the left group, separated by ``.
11
+ - Directory and git branch form the left group, separated by ``. Pi supplies the branch name; the extension asynchronously runs `git rev-list --left-right --count HEAD...@{upstream}` to append local/upstream divergence as `↑N`/`↓N` (push/pull). The counts are omitted when no upstream is configured, and Git work stays outside synchronous rendering so a slow repository cannot block the TUI.
12
12
  - Each extension status is its own `` tab. Preserve the supplied text; do not invent status labels, parentheses, or pipes.
13
13
  - Model, context, and subscription usage form the rightmost group, separated by ` · `. Model identifies the running engine; context describes its usage; the usage bar stays anchored at the right edge.
14
14
  - Powerline separators are dim. `powerlineSeparator: false` replaces them with a single space. Font availability cannot be detected reliably.
15
15
  - Measure terminal columns with `visibleWidth`, not string length. Preserve ANSI styling and wide-character accounting when truncating.
16
+ - Git divergence refreshes on Pi's branch-change signal and normal footer state refreshes; it is best-effort and never allowed to prevent the footer from loading.
16
17
 
17
18
  ### Space allocation
18
19
 
@@ -17,6 +17,8 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
17
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
18
18
  import { join, dirname, sep } from "node:path";
19
19
  import { homedir } from "node:os";
20
+ import { promisify } from "node:util";
21
+ import { execFile } from "node:child_process";
20
22
  import { BASE_BAR_CELLS, MAX_BAR_CELLS, UsageLimits } from "./lib/usage-limits.js";
21
23
 
22
24
  // ── Settings ──────────────────────────────────────────────────────────
@@ -36,6 +38,8 @@ interface Settings {
36
38
 
37
39
  type FooterSettings = NonNullable<Settings["minFooter"]>;
38
40
 
41
+ const execFileAsync = promisify(execFile);
42
+
39
43
  const DEFAULT_SETTINGS: FooterSettings = {
40
44
  enabled: true,
41
45
  showGitBranch: true,
@@ -111,6 +115,32 @@ interface FooterState {
111
115
  cwd: string;
112
116
  model: string;
113
117
  context: string;
118
+ gitSync: string;
119
+ }
120
+
121
+ async function getGitSync(cwd: string): Promise<string> {
122
+ try {
123
+ // Pi exposes the branch name, but not upstream divergence. Keep this
124
+ // asynchronous and outside render() so a slow Git repository cannot block TUI rendering.
125
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"], {
126
+ timeout: 1000,
127
+ maxBuffer: 1024,
128
+ });
129
+ // With HEAD...@{upstream}, left is local-only commits (push) and right
130
+ // is upstream-only commits (pull). An absent upstream is intentionally hidden.
131
+ const [ahead, behind] = stdout.trim().split(/\s+/).map(Number);
132
+ if (!Number.isInteger(ahead) || !Number.isInteger(behind)) return "";
133
+ return `${ahead ? ` ↑${ahead}` : ""}${behind ? ` ↓${behind}` : ""}`;
134
+ } catch {
135
+ // No repository, upstream, or reachable Git command: omit sync status.
136
+ return "";
137
+ }
138
+ }
139
+
140
+ async function updateGitSync(state: FooterState): Promise<void> {
141
+ const cwd = state.cwd;
142
+ const gitSync = await getGitSync(cwd);
143
+ if (state.cwd === cwd) state.gitSync = gitSync;
114
144
  }
115
145
 
116
146
  function updateState(ctx: ExtensionContext, state: FooterState): void {
@@ -203,7 +233,7 @@ function buildLine(
203
233
  export default function (pi: ExtensionAPI) {
204
234
  let config = readConfig();
205
235
  let enabled = config.enabled !== false;
206
- const state: FooterState = { cwd: process.cwd(), model: "no-model", context: "?" };
236
+ const state: FooterState = { cwd: process.cwd(), model: "no-model", context: "?", gitSync: "" };
207
237
  let requestRender: (() => void) | null = null;
208
238
  let disposeFooter: (() => void) | null = null;
209
239
  const usageLimits = new UsageLimits(() => requestRender?.());
@@ -226,7 +256,12 @@ export default function (pi: ExtensionAPI) {
226
256
  }
227
257
 
228
258
  ctx.ui.setFooter((tui, theme, footerData) => {
229
- const unsub = footerData.onBranchChange(() => tui.requestRender());
259
+ // Branch changes are Pi's invalidation signal; refresh the async Git
260
+ // divergence separately because footer rendering must remain synchronous.
261
+ const unsub = footerData.onBranchChange(() => {
262
+ void updateGitSync(state).then(() => tui.requestRender());
263
+ tui.requestRender();
264
+ });
230
265
  const request = () => tui.requestRender();
231
266
  requestRender = request;
232
267
  let disposed = false;
@@ -249,12 +284,13 @@ export default function (pi: ExtensionAPI) {
249
284
  ? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
250
285
  : [];
251
286
  const branch = config.showGitBranch ? footerData.getGitBranch() : null;
287
+ const branchText = branch ? `${branch}${state.gitSync}` : "";
252
288
  const statusSeparator = config.powerlineSeparator ? theme.fg("dim", "  ") : " ";
253
289
  const line = buildLine(
254
290
  width,
255
291
  config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
256
292
  statuses.length ? statusSeparator + statuses.map((status) => theme.fg("dim", status)).join(statusSeparator) : "",
257
- branch ? theme.fg("dim", ` ${branch}`) : "",
293
+ branchText ? theme.fg("dim", ` ${branchText}`) : "",
258
294
  config.showModel ? theme.bold(state.model) : "",
259
295
  config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
260
296
  (available, maxCells = BASE_BAR_CELLS) => usageLimits.line(available, theme, maxCells),
@@ -274,6 +310,7 @@ export default function (pi: ExtensionAPI) {
274
310
  /** Cheap refresh: update plain state and request one render. */
275
311
  function refresh(ctx: ExtensionContext): void {
276
312
  updateState(ctx, state);
313
+ void updateGitSync(state).then(() => requestRender?.());
277
314
  requestRender?.();
278
315
  }
279
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal-footer",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "An opinionated minimal footer extension for Pi — clean status line with path, extension statuses, model, and context usage.",
5
5
  "keywords": [
6
6
  "pi-package",