pi-minimal-footer 0.4.2 → 0.5.1

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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - Keep the whole Git section in normal text foreground while commits remain to push or pull, returning to dim when synced
15
+ - Bold changed Git divergence arrows/counts for two user submissions; further changes restart the highlight, while tool turns and extension prompts do not consume it
16
+
17
+ ## [0.5.0] - 2026-10-07
18
+
19
+ ### Added
20
+
21
+ - Show Git upstream divergence next to the branch, for example ` main ↑2 ↓1`; `↑` is local commits to push and `↓` is upstream commits to pull
22
+
23
+ ### Changed
24
+
25
+ - Query Git divergence asynchronously and omit it safely when the repository has no upstream or Git is unavailable
26
+
10
27
  ## [0.4.2] - 2026-10-07
11
28
 
12
29
  ### Fixed
@@ -186,7 +203,9 @@ First npm-ready release.
186
203
 
187
204
  - Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
188
205
 
189
- [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.4.2...HEAD
206
+ [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.5.1...HEAD
207
+ [0.5.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.5.0...v0.5.1
208
+ [0.5.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.4.2...v0.5.0
190
209
  [0.4.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.4.1...v0.4.2
191
210
  [0.4.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.4.0...v0.4.1
192
211
  [0.4.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.3...v0.4.0
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: ` main ↑2 ↓1` (`↑` to push, `↓` to pull)
18
18
 
19
19
  **Right**
20
20
 
@@ -59,11 +59,15 @@ 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
63
  - `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
64
 
65
65
  ## Reading the footer
66
66
 
67
+ ### Git
68
+
69
+ Counts require a configured upstream. Unsynced branches use the theme's normal foreground; synced branches are dim. Changed counts stay bold until two subsequent user messages; further changes restart the highlight.
70
+
67
71
  ### Context
68
72
 
69
73
  `42/200k` means tokens used / context window. `?` means Pi has not reported usage yet, including immediately after compaction.
package/VISION.md CHANGED
@@ -8,7 +8,7 @@ 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 ``.
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.
@@ -36,6 +36,14 @@ Two cells give 6.25% steps; ten give 1.25%. Fill rounds to the nearest eighth-ce
36
36
 
37
37
  The bar and reset label have no intervening space. Reset formatting uses local clock time through 24 hours, remaining whole days/hours above 24 hours, and whole days above 10 days. Exactly 24 hours stays clock time; exactly 10 days is `↻10d0h`. Missing reset metadata leaves the bar visible without a label.
38
38
 
39
+ ## Git contract
40
+
41
+ - Pi supplies the branch name. Query `git rev-list --left-right --count HEAD...@{upstream}` for `↑N` local-only and `↓N` upstream-only commits. Use local refs; never fetch automatically.
42
+ - Query outside rendering with a one-second timeout so Git cannot block the TUI. Missing upstreams and command failures omit counts.
43
+ - Refresh on branch-change signals and footer state updates. Ignore older query results to prevent stale counts from restarting highlights.
44
+ - Use the theme's normal text foreground while unsynced, otherwise dim. A count change bolds the arrows/counts until two subsequent user submissions; further changes restart the window. Tool turns and extension prompts do not consume it.
45
+ - Initial loading and working-directory changes establish an unhighlighted baseline. Zero counts hide the arrows entirely.
46
+
39
47
  ## Subscription usage contract
40
48
 
41
49
  Display only the selected provider's shortest applicable window. Subscription usage and model context usage are separate measurements.
@@ -95,12 +103,18 @@ For a manual install, copy `extensions/*` into `~/.pi/agent/extensions/`.
95
103
  - `extensions/index.ts`: settings, state refresh, layout, footer lifecycle, and `/minfooter`.
96
104
  - `extensions/lib/usage-limits.ts`: usage refresh, cache, cancellation, and bar/reset rendering.
97
105
  - `extensions/lib/usage-providers.ts`: authentication, endpoint selection, and response normalization.
98
- - `tests/`: loader-based lifecycle/layout regression tests and usage parsing tests.
106
+ - `tests/`: lifecycle/layout regressions, Git highlighting with a temporary repository, and usage parsing tests.
99
107
 
100
108
  Settings live under `minFooter` in Pi's configured agent directory. Read them at session start or explicit toggle; ordinary refreshes use cached settings. Preserve unrelated settings on writes. Only install the footer in interactive terminal mode.
101
109
 
102
110
  ### Checking changes
103
111
 
104
- For behavior changes, run `npm run check` and `npm test`. Layout tests should cover narrow widths, ANSI text, wide Unicode, field priority, and both separator modes. Subscription usage changes should cover provider switches, timeouts, stale cache, partial updates, and malformed/missing metadata as applicable. Preview in a real terminal for font-dependent appearance; automated width checks cannot prove glyph availability.
112
+ Run `npm run check` and `npm test` for behavior changes. Cover relevant cases:
113
+
114
+ - Layout: narrow widths, ANSI/wide Unicode, field priority, and separator modes.
115
+ - Git: divergence, synced styling, highlight expiry/restart, and user submissions versus tool turns.
116
+ - Subscription usage: provider switches, timeouts, stale cache, partial updates, and malformed/missing metadata.
117
+
118
+ Preview font-dependent appearance in a real terminal; width checks cannot prove glyph availability.
105
119
 
106
120
  Keep release notes in [CHANGELOG.md](CHANGELOG.md), and keep the README focused on installation and daily use. Preserve [upstream MIT attribution](extensions/lib/LICENSE) when changing adapted provider code.
@@ -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,10 +115,50 @@ interface FooterState {
111
115
  cwd: string;
112
116
  model: string;
113
117
  context: string;
118
+ gitSync: string;
119
+ gitInitialized: boolean;
120
+ gitHighlightInputs: number;
121
+ gitRequest: number;
122
+ }
123
+
124
+ async function getGitSync(cwd: string): Promise<string> {
125
+ try {
126
+ // Pi exposes the branch name, but not upstream divergence. Keep this
127
+ // asynchronous and outside render() so a slow Git repository cannot block TUI rendering.
128
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"], {
129
+ timeout: 1000,
130
+ maxBuffer: 1024,
131
+ });
132
+ // With HEAD...@{upstream}, left is local-only commits (push) and right
133
+ // is upstream-only commits (pull). An absent upstream is intentionally hidden.
134
+ const [ahead, behind] = stdout.trim().split(/\s+/).map(Number);
135
+ if (!Number.isInteger(ahead) || !Number.isInteger(behind)) return "";
136
+ return `${ahead ? ` ↑${ahead}` : ""}${behind ? ` ↓${behind}` : ""}`;
137
+ } catch {
138
+ // No repository, upstream, or reachable Git command: omit sync status.
139
+ return "";
140
+ }
141
+ }
142
+
143
+ async function updateGitSync(state: FooterState): Promise<void> {
144
+ const cwd = state.cwd;
145
+ const request = ++state.gitRequest;
146
+ const gitSync = await getGitSync(cwd);
147
+ // Refresh events can overlap; an older result must not restart the highlight.
148
+ if (state.cwd !== cwd || request !== state.gitRequest) return;
149
+ if (state.gitInitialized && state.gitSync !== gitSync) state.gitHighlightInputs = 2;
150
+ state.gitSync = gitSync;
151
+ state.gitInitialized = true;
114
152
  }
115
153
 
116
154
  function updateState(ctx: ExtensionContext, state: FooterState): void {
117
- state.cwd = ctx.cwd ?? process.cwd();
155
+ const cwd = ctx.cwd ?? process.cwd();
156
+ if (state.cwd !== cwd) {
157
+ state.gitSync = "";
158
+ state.gitInitialized = false;
159
+ state.gitHighlightInputs = 0;
160
+ }
161
+ state.cwd = cwd;
118
162
  state.model = ctx.model?.id ?? "no-model";
119
163
  const usage = ctx.getContextUsage();
120
164
  if (usage) {
@@ -203,7 +247,10 @@ function buildLine(
203
247
  export default function (pi: ExtensionAPI) {
204
248
  let config = readConfig();
205
249
  let enabled = config.enabled !== false;
206
- const state: FooterState = { cwd: process.cwd(), model: "no-model", context: "?" };
250
+ const state: FooterState = {
251
+ cwd: process.cwd(), model: "no-model", context: "?", gitSync: "",
252
+ gitInitialized: false, gitHighlightInputs: 0, gitRequest: 0,
253
+ };
207
254
  let requestRender: (() => void) | null = null;
208
255
  let disposeFooter: (() => void) | null = null;
209
256
  const usageLimits = new UsageLimits(() => requestRender?.());
@@ -226,7 +273,12 @@ export default function (pi: ExtensionAPI) {
226
273
  }
227
274
 
228
275
  ctx.ui.setFooter((tui, theme, footerData) => {
229
- const unsub = footerData.onBranchChange(() => tui.requestRender());
276
+ // Branch changes are Pi's invalidation signal; refresh the async Git
277
+ // divergence separately because footer rendering must remain synchronous.
278
+ const unsub = footerData.onBranchChange(() => {
279
+ void updateGitSync(state).then(() => tui.requestRender());
280
+ tui.requestRender();
281
+ });
230
282
  const request = () => tui.requestRender();
231
283
  requestRender = request;
232
284
  let disposed = false;
@@ -249,12 +301,15 @@ export default function (pi: ExtensionAPI) {
249
301
  ? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
250
302
  : [];
251
303
  const branch = config.showGitBranch ? footerData.getGitBranch() : null;
304
+ const syncText = state.gitHighlightInputs > 0 ? theme.bold(state.gitSync) : state.gitSync;
305
+ const branchText = branch ? `${branch}${syncText}` : "";
252
306
  const statusSeparator = config.powerlineSeparator ? theme.fg("dim", "  ") : " ";
253
307
  const line = buildLine(
254
308
  width,
255
309
  config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
256
310
  statuses.length ? statusSeparator + statuses.map((status) => theme.fg("dim", status)).join(statusSeparator) : "",
257
- branch ? theme.fg("dim", ` ${branch}`) : "",
311
+ // Keep unsynced branches readable; bold counts separately mark recent changes.
312
+ branchText ? theme.fg(state.gitSync ? "text" : "dim", ` ${branchText}`) : "",
258
313
  config.showModel ? theme.bold(state.model) : "",
259
314
  config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
260
315
  (available, maxCells = BASE_BAR_CELLS) => usageLimits.line(available, theme, maxCells),
@@ -274,6 +329,7 @@ export default function (pi: ExtensionAPI) {
274
329
  /** Cheap refresh: update plain state and request one render. */
275
330
  function refresh(ctx: ExtensionContext): void {
276
331
  updateState(ctx, state);
332
+ void updateGitSync(state).then(() => requestRender?.());
277
333
  requestRender?.();
278
334
  }
279
335
 
@@ -285,6 +341,13 @@ export default function (pi: ExtensionAPI) {
285
341
  install(ctx);
286
342
  });
287
343
 
344
+ // Count user submissions, not turn_end: one prompt can trigger many tool turns.
345
+ // Extension-generated prompts do not consume the user's highlight window.
346
+ pi.on("input", (event, ctx) => {
347
+ if (event.source !== "extension" && state.gitHighlightInputs > 0) state.gitHighlightInputs--;
348
+ refresh(ctx);
349
+ });
350
+
288
351
  pi.on("model_select", async (_event, ctx) => {
289
352
  selectUsage(ctx);
290
353
  refresh(ctx);
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.1",
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",