pi-minimal-footer 0.5.3 → 0.7.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,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-10-13
11
+
12
+ ### Fixed
13
+
14
+ - Remove an unused, completed implementation plan from the documentation
15
+
16
+ ### Added
17
+
18
+ - Programmatically enable or disable the footer for the current session without changing saved settings:
19
+ ```typescript
20
+ pi.events.emit("pi-minimal-footer:set-enabled", { enabled: false }); // disable
21
+ pi.events.emit("pi-minimal-footer:set-enabled", { enabled: true }); // enable
22
+ ```
23
+
24
+ ### Changed
25
+
26
+ - Strengthen TypeScript checks and make provider endpoint lookups explicit
27
+ - Refine the package description and discovery keywords
28
+ - Shorten the README introduction and use a thinner footer preview; keep the Pi package preview unchanged
29
+
30
+ ## [0.6.0] - 2026-10-08
31
+
32
+ ### Fixed
33
+
34
+ - Run Git queries only while the extension owns a live TUI footer; explicit enable resumes querying
35
+ - Reject Git refresh and fetch timeout settings above Node's maximum timer delay, preserving the 91- and 17-second fallbacks
36
+ - Replace fixed sleeps in Git fetch jitter tests with bounded waits for child completion and retry scheduling
37
+
38
+ ### Changed
39
+
40
+ - Keep the README user-focused; move compatibility and plugin API details to the developer guide
41
+ - Document the rationale for brief Git pulses and live-footer refresh ownership
42
+ - Pulse the whole Git section bold for one second when divergence changes, replacing the two-user-submission count highlight; preserve unsynced/synced foreground colors and cancel the pulse on disposal
43
+
44
+ ### Added
45
+
46
+ - Opt-in `minFooter.gitFetch` background fetch on footer startup and every five minutes with ±⅛ jitter; silent failures, no interactive credential prompts, configurable `gitFetchTimeoutSeconds` (default 17), and cancellation on footer disposal
47
+
10
48
  ## [0.5.3] - 2026-10-07
11
49
 
12
50
  ### Changed
@@ -216,7 +254,9 @@ First npm-ready release.
216
254
 
217
255
  - Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
218
256
 
219
- [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.5.3...HEAD
257
+ [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.7.0...HEAD
258
+ [0.7.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.6.0...v0.7.0
259
+ [0.6.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.5.3...v0.6.0
220
260
  [0.5.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.5.2...v0.5.3
221
261
  [0.5.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.5.1...v0.5.2
222
262
  [0.5.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.5.0...v0.5.1
package/README.md CHANGED
@@ -7,9 +7,9 @@
7
7
  <a href="https://pi.dev/packages/pi-minimal-footer"><img src="https://img.shields.io/badge/pi-package-1a1a2e" alt="pi package"></a>
8
8
  </p>
9
9
 
10
- Pi minimal footer is an opinionated, compact, one-line status bar for [Pi](https://github.com/earendil-works/pi).
10
+ An opinionated, compact, one-line status bar for [Pi](https://github.com/earendil-works/pi).
11
11
 
12
- ![Footer preview: ~/git/project   main  🧠 Karpathy  🪽 Icarus  model · 42/200k · [⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀]↻1d8h](media/github-preview.png)
12
+ ![Footer preview: ~/git/project   main  🧠 Karpathy  🪽 Icarus  model · 42/200k · [⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀]↻1d8h](media/preview.png)
13
13
 
14
14
  **Left**
15
15
 
@@ -35,10 +35,7 @@ pi install npm:pi-minimal-footer
35
35
  pi install npm:@ryu-cz/pi-minimal-footer
36
36
  ```
37
37
 
38
- `pi-minimal-footer` remains the original, unscoped release path. The scoped
39
- `@ryu-cz/pi-minimal-footer` package is an official mirror intended to improve
40
- package indexing; it is not a separate extension. The scoped name is an
41
- indexing workaround, not a guaranteed fix for pi.dev catalog search.
38
+ The scoped package is an official mirror, not a separate extension.
42
39
 
43
40
  ## Commands
44
41
 
@@ -63,6 +60,8 @@ Edit `minFooter` in Pi's agent `settings.json` (normally `~/.pi/agent/settings.j
63
60
  "showContext": true,
64
61
  "powerlineSeparator": true,
65
62
  "gitRefreshSeconds": 91,
63
+ "gitFetch": false,
64
+ "gitFetchTimeoutSeconds": 17,
66
65
  "maxUsageBarCells": 10
67
66
  }
68
67
  }
@@ -70,14 +69,16 @@ Edit `minFooter` in Pi's agent `settings.json` (normally `~/.pi/agent/settings.j
70
69
 
71
70
  - `showSkills` shows **extension status text**, not installed skills. The footer does not add the example statuses itself.
72
71
  - 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.
73
- - `gitRefreshSeconds`: local Git refresh interval; positive integer seconds; default/fallback `91`; no remote fetch.
72
+ - `gitRefreshSeconds`: local refresh interval; `1`–`2147483` seconds; default `91`.
73
+ - `gitFetch`: background fetch on startup, then roughly 5 minutes after each attempt completes; failures are silent; default `false`.
74
+ - `gitFetchTimeoutSeconds`: fetch timeout; `1`–`2147483` seconds; default `17`. Invalid timer settings use their defaults.
74
75
  - `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`.
75
76
 
76
77
  ## Reading the footer
77
78
 
78
79
  ### Git
79
80
 
80
- 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.
81
+ Counts require a configured upstream. Unsynced branches use normal text color; synced branches are dim. Count changes pulse the whole Git section bold for one second; further changes restart the pulse.
81
82
 
82
83
  ### Context
83
84
 
@@ -92,8 +93,44 @@ Counts require a configured upstream. Unsynced branches use the theme's normal f
92
93
  - **Cached data:** dim after a refresh failure.
93
94
  - Reset: local `↻HH:mm` through 24 hours; whole days/hours above 24 hours (`↻1d8h`); whole days above 10 days (`↻12d`). Remaining durations round down. No reset reported means bar only.
94
95
  - Shows the shortest available usage window for the selected provider. Missing credentials, unsupported/local models, or absent limits hide the usage bar. Custom proxy endpoints are not polled.
96
+ - Virtual/routed models show model and context usage, but no subscription bar.
95
97
 
96
- Implemented adapters: **OpenAI Codex, Claude OAuth, GitHub Copilot, Gemini CLI, MiniMax, Kimi Coding, and OpenCode Go**. Uses Pi's existing credentials; ordinary Claude API keys do not expose subscription usage. Authenticated endpoints remain unverified live; see [verification limits](VISION.md).
98
+ #### Adapters
99
+
100
+ Adapters use Pi's existing credentials; ordinary Claude API keys do not expose subscription usage.
101
+
102
+ *Authenticated endpoints remain unverified live; see [verification limits](VISION.md).*
103
+
104
+ Implemented adapters:
105
+ - **OpenAI Codex**
106
+ - **Claude OAuth**
107
+ - **GitHub Copilot**
108
+ - **Gemini CLI**
109
+ - **MiniMax**
110
+ - **Kimi Coding**
111
+ - **OpenCode Go**
112
+
113
+ ### Extension statuses
114
+
115
+ Provided by other extensions; hidden with `showSkills: false`. Narrow terminals may shorten or omit them.
116
+
117
+ Only one footer extension can be active. For plugin integration, see [VISION.md](VISION.md#plugin-integration).
118
+
119
+ ## Programmatic control
120
+
121
+ Other Pi extensions can enable or disable the footer for the current session without changing saved settings:
122
+
123
+ ```typescript
124
+ pi.events.emit("pi-minimal-footer:set-enabled", { enabled: false }); // disable
125
+ pi.events.emit("pi-minimal-footer:set-enabled", { enabled: true }); // enable
126
+ ```
127
+
128
+ - Events work only while this extension's session is active.
129
+ - The latest command or event wins.
130
+ - `/minfooter on/off` saves your preference into config; events don't.
131
+ - Enabling may replace another footer.
132
+ - Disabling restores Pi's default footer if this footer is active; it leaves another extension's active footer untouched.
133
+ - New sessions and `/reload` use your saved preference.
97
134
 
98
135
  ## Development & design
99
136
 
@@ -103,7 +140,7 @@ Install from source:
103
140
  pi install git:github.com/Ryu-CZ/pi-minimal-footer
104
141
  ```
105
142
 
106
- See [VISION.md](VISION.md) for design constraints, local development, provider details, and verification limits. Release history: [CHANGELOG.md](CHANGELOG.md).
143
+ Developer guide: [VISION.md](VISION.md). Release history: [CHANGELOG.md](CHANGELOG.md).
107
144
 
108
145
  ## Credits
109
146
 
package/VISION.md CHANGED
@@ -38,11 +38,12 @@ The bar and reset label have no intervening space. Reset formatting uses local c
38
38
 
39
39
  ## Git contract
40
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.
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; no remote fetch by default.
42
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, plus every 91 seconds while the footer is active so external commits are noticed even when Pi is idle. `minFooter.gitRefreshSeconds` accepts positive integer seconds; invalid values fall back to 91. Local-only queries permit a shorter interval than subscription usage polling without network traffic. Stop the timer on footer disposal. 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.
43
+ - Refresh on branch-change signals and footer state updates, plus every 91 seconds while the footer is active so external commits are noticed even when Pi is idle. `minFooter.gitRefreshSeconds` accepts integer seconds from 1 through 2,147,483 (Node's maximum timer delay); invalid values fall back to 91. Local-only queries permit a shorter interval than subscription usage polling without network traffic. Stop the timer on footer disposal. 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 pulses the whole Git section (icon, branch, and counts) bold for one second to call attention without persistent emphasis or turn-count bookkeeping; this is independent of synced/unsynced foreground color. Further changes restart the timer. Expiry requests a redraw even while idle. User submissions and tool turns do not affect the pulse. Dispose cancels the timer.
45
45
  - Initial loading and working-directory changes establish an unhighlighted baseline. Zero counts hide the arrows entirely.
46
+ - `minFooter.gitFetch` (default `false`) opts into asynchronous `git fetch --quiet` on footer startup and every five minutes with ±⅛ jitter (262.5–337.5 seconds), sampled after each completed attempt. Jitter spreads network load across sessions; local divergence polling remains independent. Disable interactive credential prompts; failures are silent and retry at the next interval. `gitFetchTimeoutSeconds` accepts integer seconds from 1 through 2,147,483 (Node's maximum timer delay), default/fallback 17. Successful fetches refresh divergence. Footer disposal clears the timer and aborts an in-flight fetch; only interactive sessions with an active footer fetch.
46
47
 
47
48
  ## Subscription usage contract
48
49
 
@@ -79,7 +80,7 @@ The comparison with [mtrojnar/pi-usage](https://github.com/mtrojnar/pi-usage) in
79
80
 
80
81
  ## Local development
81
82
 
82
- Requires Node.js >=22.19.0. Tests currently use Pi 1.0.3 and mock provider requests.
83
+ Requires Node.js >=22.19.0. Local dependencies pin Pi 1.0.3; compatibility CI tests Pi 1.0.3 and 1.1.0 on Node.js 22.19.0. Provider requests are mocked. These are tested versions, not a guarantee for every intervening or future release.
83
84
 
84
85
  ```bash
85
86
  npm ci
@@ -98,9 +99,38 @@ ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
98
99
 
99
100
  For a manual install, copy `extensions/*` into `~/.pi/agent/extensions/`.
100
101
 
102
+ ### Plugin integration
103
+
104
+ Use Pi's standard status API to contribute text without replacing the footer:
105
+
106
+ ```typescript
107
+ ctx.ui.setStatus("my-extension", "Working");
108
+ ctx.ui.setStatus("my-extension", "Ready"); // update the same entry
109
+ ctx.ui.setStatus("my-extension", undefined); // remove it
110
+ ```
111
+
112
+ Use a unique key. Pi requests a redraw when statuses change; no footer-specific event is needed. `showSkills` controls status visibility. Narrow terminals may truncate or omit entries to preserve model/context and usage information.
113
+
114
+ Footer ownership is exclusive: another extension calling `ctx.ui.setFooter()` replaces this footer. Usage requests, new Git queries, and footer-owned timers stop on disposal; an in-flight Git query may complete, but lifecycle state refreshes alone never start one.
115
+
116
+ #### Session-only footer control
117
+
118
+ Extensions can temporarily override footer visibility without changing the user's saved setting:
119
+
120
+ ```typescript
121
+ pi.events.emit("pi-minimal-footer:set-enabled", { enabled: false });
122
+ pi.events.emit("pi-minimal-footer:set-enabled", { enabled: true });
123
+ ```
124
+
125
+ Emit after this extension's `session_start` handler has run; events before startup or after shutdown are ignored. The payload must contain a boolean `enabled`; invalid payloads are ignored. Changes are silent and do not write settings.
126
+
127
+ The latest explicit action wins: `/minfooter on/off` changes the current footer and saves the preference, while this event changes only the current session. Normal refreshes never undo the choice. A new session or extension reload starts from the saved preference.
128
+
129
+ Disabling restores Pi's default footer only if we still own the active footer; otherwise it leaves the active footer untouched. Enabling explicitly installs our footer and may replace another extension's footer; repeated enables while we own it do nothing. Neither action restores a previous extension's footer. A coordinating extension must choose the active footer—there is no priority stack or automatic reactivation.
130
+
101
131
  ### Code map
102
132
 
103
- - `extensions/index.ts`: settings, state refresh, layout, footer lifecycle, and `/minfooter`.
133
+ - `extensions/index.ts`: settings, plain state updates, active-footer refresh gate, layout, footer lifecycle, and `/minfooter`.
104
134
  - `extensions/lib/usage-limits.ts`: usage refresh, cache, cancellation, and bar/reset rendering.
105
135
  - `extensions/lib/usage-providers.ts`: authentication, endpoint selection, and response normalization.
106
136
  - `tests/`: lifecycle/layout regressions, Git highlighting with a temporary repository, and usage parsing tests.
@@ -33,6 +33,8 @@ interface Settings {
33
33
  showContext?: boolean;
34
34
  powerlineSeparator?: boolean;
35
35
  gitRefreshSeconds?: number;
36
+ gitFetch?: boolean;
37
+ gitFetchTimeoutSeconds?: number;
36
38
  maxUsageBarCells?: number;
37
39
  };
38
40
  }
@@ -40,6 +42,8 @@ interface Settings {
40
42
  type FooterSettings = NonNullable<Settings["minFooter"]>;
41
43
 
42
44
  const execFileAsync = promisify(execFile);
45
+ // Node clamps timer delays above this signed 32-bit millisecond limit to 1ms.
46
+ const MAX_TIMER_SECONDS = Math.floor(2_147_483_647 / 1000);
43
47
 
44
48
  const DEFAULT_SETTINGS: FooterSettings = {
45
49
  enabled: true,
@@ -50,6 +54,8 @@ const DEFAULT_SETTINGS: FooterSettings = {
50
54
  showContext: true,
51
55
  powerlineSeparator: true,
52
56
  gitRefreshSeconds: 91,
57
+ gitFetch: false,
58
+ gitFetchTimeoutSeconds: 17,
53
59
  maxUsageBarCells: MAX_BAR_CELLS,
54
60
  };
55
61
 
@@ -83,9 +89,12 @@ function writeSettings(patch: Partial<Settings>): void {
83
89
  function readConfig(): FooterSettings {
84
90
  const s = readSettings();
85
91
  const config = { ...DEFAULT_SETTINGS, ...s.minFooter };
86
- if (typeof config.gitRefreshSeconds !== "number" || !Number.isSafeInteger(config.gitRefreshSeconds) || config.gitRefreshSeconds < 1) {
92
+ if (typeof config.gitRefreshSeconds !== "number" || !Number.isSafeInteger(config.gitRefreshSeconds) || config.gitRefreshSeconds < 1 || config.gitRefreshSeconds > MAX_TIMER_SECONDS) {
87
93
  config.gitRefreshSeconds = 91;
88
94
  }
95
+ if (typeof config.gitFetchTimeoutSeconds !== "number" || !Number.isSafeInteger(config.gitFetchTimeoutSeconds) || config.gitFetchTimeoutSeconds < 1 || config.gitFetchTimeoutSeconds > MAX_TIMER_SECONDS) {
96
+ config.gitFetchTimeoutSeconds = 17;
97
+ }
89
98
  if (typeof config.maxUsageBarCells !== "number" || !Number.isSafeInteger(config.maxUsageBarCells) || config.maxUsageBarCells < 1) {
90
99
  config.maxUsageBarCells = MAX_BAR_CELLS;
91
100
  }
@@ -122,7 +131,7 @@ interface FooterState {
122
131
  context: string;
123
132
  gitSync: string;
124
133
  gitInitialized: boolean;
125
- gitHighlightInputs: number;
134
+ gitHighlighted: boolean;
126
135
  gitRequest: number;
127
136
  }
128
137
 
@@ -145,15 +154,16 @@ async function getGitSync(cwd: string): Promise<string> {
145
154
  }
146
155
  }
147
156
 
148
- async function updateGitSync(state: FooterState): Promise<void> {
157
+ async function updateGitSync(state: FooterState): Promise<boolean> {
149
158
  const cwd = state.cwd;
150
159
  const request = ++state.gitRequest;
151
160
  const gitSync = await getGitSync(cwd);
152
161
  // Refresh events can overlap; an older result must not restart the highlight.
153
- if (state.cwd !== cwd || request !== state.gitRequest) return;
154
- if (state.gitInitialized && state.gitSync !== gitSync) state.gitHighlightInputs = 2;
162
+ if (state.cwd !== cwd || request !== state.gitRequest) return false;
163
+ const changed = state.gitInitialized && state.gitSync !== gitSync;
155
164
  state.gitSync = gitSync;
156
165
  state.gitInitialized = true;
166
+ return changed;
157
167
  }
158
168
 
159
169
  function updateState(ctx: ExtensionContext, state: FooterState): void {
@@ -161,7 +171,7 @@ function updateState(ctx: ExtensionContext, state: FooterState): void {
161
171
  if (state.cwd !== cwd) {
162
172
  state.gitSync = "";
163
173
  state.gitInitialized = false;
164
- state.gitHighlightInputs = 0;
174
+ state.gitHighlighted = false;
165
175
  }
166
176
  state.cwd = cwd;
167
177
  state.model = ctx.model?.id ?? "no-model";
@@ -252,13 +262,30 @@ function buildLine(
252
262
  export default function (pi: ExtensionAPI) {
253
263
  let config = readConfig();
254
264
  let enabled = config.enabled !== false;
265
+ let currentCtx: ExtensionContext | undefined;
255
266
  const state: FooterState = {
256
267
  cwd: process.cwd(), model: "no-model", context: "?", gitSync: "",
257
- gitInitialized: false, gitHighlightInputs: 0, gitRequest: 0,
268
+ gitInitialized: false, gitHighlighted: false, gitRequest: 0,
258
269
  };
259
270
  let requestRender: (() => void) | null = null;
260
271
  let disposeFooter: (() => void) | null = null;
261
272
  const usageLimits = new UsageLimits(() => requestRender?.());
273
+ let gitPulseTimer: ReturnType<typeof setTimeout> | null = null;
274
+
275
+ // Pulse the whole section for one second to call attention without persistent emphasis
276
+ // or turn-count bookkeeping; foreground color independently shows sync state.
277
+ // Design contract: VISION.md#git-contract.
278
+ async function refreshGit(): Promise<void> {
279
+ if (!await updateGitSync(state) || disposeFooter === null) return;
280
+ if (gitPulseTimer) clearTimeout(gitPulseTimer);
281
+ state.gitHighlighted = true;
282
+ gitPulseTimer = setTimeout(() => {
283
+ gitPulseTimer = null;
284
+ state.gitHighlighted = false;
285
+ requestRender?.();
286
+ }, 1000);
287
+ gitPulseTimer.unref?.();
288
+ }
262
289
 
263
290
  function selectUsage(ctx: ExtensionContext): void {
264
291
  // The preference may remain enabled after another extension replaces our footer.
@@ -268,7 +295,6 @@ export default function (pi: ExtensionAPI) {
268
295
 
269
296
  function install(ctx: ExtensionContext): void {
270
297
  config = readConfig();
271
- enabled = config.enabled !== false;
272
298
  if (ctx.mode !== "tui") { usageLimits.stop(); return; }
273
299
 
274
300
  if (!enabled) {
@@ -281,7 +307,7 @@ export default function (pi: ExtensionAPI) {
281
307
  // Branch changes are Pi's invalidation signal; refresh the async Git
282
308
  // divergence separately because footer rendering must remain synchronous.
283
309
  const unsub = footerData.onBranchChange(() => {
284
- void updateGitSync(state).then(() => tui.requestRender());
310
+ void refreshGit().then(() => tui.requestRender());
285
311
  tui.requestRender();
286
312
  });
287
313
  const request = () => tui.requestRender();
@@ -295,22 +321,61 @@ export default function (pi: ExtensionAPI) {
295
321
  if (disposed) return;
296
322
  gitTimer = setTimeout(() => {
297
323
  gitTimer = null;
298
- void updateGitSync(state).then(() => tui.requestRender()).finally(scheduleGitRefresh);
324
+ void refreshGit().then(() => tui.requestRender()).finally(scheduleGitRefresh);
299
325
  }, gitRefreshMs);
300
326
  // Do not keep a non-interactive process alive solely for footer refresh.
301
327
  gitTimer.unref?.();
302
328
  };
303
329
  scheduleGitRefresh();
330
+ let fetchTimer: ReturnType<typeof setTimeout> | null = null;
331
+ let fetchController: AbortController | null = null;
332
+ const fetchTimeoutMs = (config.gitFetchTimeoutSeconds ?? 17) * 1000;
333
+ const fetchGit = async () => {
334
+ if (disposed) return;
335
+ const cwd = state.cwd;
336
+ fetchController = new AbortController();
337
+ try {
338
+ // Never prompt for credentials in a background job. Jitter spreads
339
+ // network requests from concurrent Pi sessions; local polling stays independent.
340
+ await execFileAsync("git", ["-C", cwd, "fetch", "--quiet"], {
341
+ timeout: fetchTimeoutMs,
342
+ signal: fetchController.signal,
343
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
344
+ });
345
+ if (!disposed && state.cwd === cwd) {
346
+ await refreshGit();
347
+ if (!disposed) tui.requestRender();
348
+ }
349
+ } catch {
350
+ // Offline, authentication, timeout and cancellation failures are silent.
351
+ } finally {
352
+ fetchController = null;
353
+ if (!disposed) {
354
+ fetchTimer = setTimeout(() => {
355
+ fetchTimer = null;
356
+ void fetchGit();
357
+ }, 5 * 60 * 1000 * (1 + (Math.random() * 2 - 1) / 8));
358
+ fetchTimer.unref?.();
359
+ }
360
+ }
361
+ };
362
+ if (config.gitFetch === true) void fetchGit();
304
363
  const dispose = () => {
305
364
  if (disposed) return;
306
365
  disposed = true;
307
366
  unsub();
308
367
  if (gitTimer) clearTimeout(gitTimer);
309
368
  gitTimer = null;
369
+ if (fetchTimer) clearTimeout(fetchTimer);
370
+ fetchTimer = null;
371
+ fetchController?.abort();
310
372
  // A superseded renderer must not stop its replacement's usage polling.
311
373
  if (requestRender === request) {
312
374
  requestRender = null;
313
375
  usageLimits.stop();
376
+ if (gitPulseTimer) clearTimeout(gitPulseTimer);
377
+ gitPulseTimer = null;
378
+ state.gitHighlighted = false;
314
379
  }
315
380
  if (disposeFooter === dispose) disposeFooter = null;
316
381
  };
@@ -322,15 +387,15 @@ export default function (pi: ExtensionAPI) {
322
387
  ? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
323
388
  : [];
324
389
  const branch = config.showGitBranch ? footerData.getGitBranch() : null;
325
- const syncText = state.gitHighlightInputs > 0 ? theme.bold(state.gitSync) : state.gitSync;
326
- const branchText = branch ? `${branch}${syncText}` : "";
390
+ const branchText = branch ? ` ${branch}${state.gitSync}` : "";
391
+ const gitText = state.gitHighlighted ? theme.bold(branchText) : branchText;
327
392
  const statusSeparator = config.powerlineSeparator ? theme.fg("dim", "  ") : " ";
328
393
  const line = buildLine(
329
394
  width,
330
395
  config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
331
396
  statuses.length ? statusSeparator + statuses.map((status) => theme.fg("dim", status)).join(statusSeparator) : "",
332
- // Keep unsynced branches readable; bold counts separately mark recent changes.
333
- branchText ? theme.fg(state.gitSync ? "text" : "dim", ` ${branchText}`) : "",
397
+ // Pulse weight independently of the existing divergence color.
398
+ branchText ? theme.fg(state.gitSync ? "text" : "dim", gitText) : "",
334
399
  config.showModel ? theme.bold(state.model) : "",
335
400
  config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
336
401
  (available, maxCells = BASE_BAR_CELLS) => usageLimits.line(available, theme, maxCells),
@@ -347,25 +412,40 @@ export default function (pi: ExtensionAPI) {
347
412
  selectUsage(ctx);
348
413
  }
349
414
 
350
- /** Cheap refresh: update plain state and request one render. */
415
+ /** Gate active work on a live footer so passive state updates cannot resurrect hidden Git queries. */
416
+ function refreshFooter(): void {
417
+ if (disposeFooter === null) return;
418
+ void refreshGit().then(() => requestRender?.());
419
+ requestRender?.();
420
+ }
421
+
351
422
  function refresh(ctx: ExtensionContext): void {
352
423
  updateState(ctx, state);
353
- void updateGitSync(state).then(() => requestRender?.());
354
- requestRender?.();
424
+ refreshFooter();
355
425
  }
356
426
 
357
427
  // ── Lifecycle events (passive; no footer re-install) ────────────────
358
428
 
429
+ pi.events.on("pi-minimal-footer:set-enabled", (data) => {
430
+ if (!currentCtx || typeof data !== "object" || data === null ||
431
+ !("enabled" in data) || typeof data.enabled !== "boolean") return;
432
+ if (enabled === data.enabled && (!enabled || disposeFooter !== null)) return;
433
+ enabled = data.enabled;
434
+ updateState(currentCtx, state);
435
+ install(currentCtx);
436
+ refreshFooter();
437
+ });
438
+
359
439
  pi.on("session_start", async (_event, ctx) => {
440
+ currentCtx = ctx;
441
+ enabled = readConfig().enabled !== false;
360
442
  usageLimits.stop();
361
- refresh(ctx);
443
+ updateState(ctx, state);
362
444
  install(ctx);
445
+ refreshFooter();
363
446
  });
364
447
 
365
- // Count user submissions, not turn_end: one prompt can trigger many tool turns.
366
- // Extension-generated prompts do not consume the user's highlight window.
367
- pi.on("input", (event, ctx) => {
368
- if (event.source !== "extension" && state.gitHighlightInputs > 0) state.gitHighlightInputs--;
448
+ pi.on("input", (_event, ctx) => {
369
449
  refresh(ctx);
370
450
  });
371
451
 
@@ -399,6 +479,7 @@ export default function (pi: ExtensionAPI) {
399
479
  });
400
480
 
401
481
  pi.on("session_shutdown", async () => {
482
+ currentCtx = undefined;
402
483
  usageLimits.stop();
403
484
  disposeFooter?.();
404
485
  });
@@ -412,8 +493,9 @@ export default function (pi: ExtensionAPI) {
412
493
  else if (args === "off") enabled = false;
413
494
  else enabled = !enabled;
414
495
  writeEnabled(enabled);
415
- refresh(ctx);
496
+ updateState(ctx, state);
416
497
  install(ctx);
498
+ refreshFooter();
417
499
  ctx.ui.notify(`Minimal footer ${enabled ? "enabled" : "disabled"}`, "info");
418
500
  },
419
501
  });
@@ -197,7 +197,7 @@ export class UsageLimits {
197
197
  if (!this.ctx || this.request) return;
198
198
  const selected = this.selectedWindow();
199
199
  // Keep the sampled deadline when unrelated windows reschedule the timer.
200
- let next = Math.max(this.attemptedAt, selected ? this.updatedAt[selected[0]] : 0) + this.refreshDelay;
200
+ let next = Math.max(this.attemptedAt, selected ? this.updatedAt[selected[0]] ?? 0 : 0) + this.refreshDelay;
201
201
  const resetAt = selected?.[1].resetAt;
202
202
  if (resetAt != null && resetAt > this.attemptedAt) next = Math.min(next, resetAt);
203
203
  this.timer = setTimeout(() => { this.timer = null; void this.poll(); }, Math.max(1, next - Date.now()));
@@ -73,6 +73,8 @@ function storedOAuth(provider: string): Record<string, unknown> {
73
73
 
74
74
  export async function usageRequest(ctx: ExtensionContext, model: NonNullable<ExtensionContext["model"]>): Promise<{ url: string; init: RequestInit } | null> {
75
75
  const provider = model.provider;
76
+ const url = ENDPOINTS[provider];
77
+ if (!url) return null;
76
78
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
77
79
  if (!auth.ok) {
78
80
  // Pi exposes missing credentials as a message, alongside transient OAuth errors.
@@ -110,7 +112,7 @@ export async function usageRequest(ctx: ExtensionContext, model: NonNullable<Ext
110
112
  init.body = "{}";
111
113
  headers["Content-Type"] = "application/json";
112
114
  }
113
- return { url: ENDPOINTS[provider], init };
115
+ return { url, init };
114
116
  }
115
117
 
116
118
  function isObject(value: unknown): value is Record<string, unknown> {
@@ -148,7 +150,7 @@ export function parseUsageWindows(provider: string, model: string, payload: unkn
148
150
  recognized = true;
149
151
  if (data.rate_limit !== null && !isObject(data.rate_limit)) malformed = true;
150
152
  const limits = record(data.rate_limit);
151
- for (const [key, name] of [["primary", "primary_window"], ["secondary", "secondary_window"]]) {
153
+ for (const [key, name] of [["primary", "primary_window"], ["secondary", "secondary_window"]] as const) {
152
154
  add(key, limits[name], (w) => windowFrom(number(w.used_percent),
153
155
  Object.hasOwn(w, "limit_window_seconds") ? number(w.limit_window_seconds) ?? NaN : undefined,
154
156
  resetSeconds(w.reset_at) ?? relativeReset(w.reset_after_seconds)));
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "pi-minimal-footer",
3
- "version": "0.5.3",
4
- "description": "An opinionated minimal footer extension for Pi — clean status line with path, extension statuses, model, and context usage.",
3
+ "version": "0.7.0",
4
+ "description": "An opinionated minimal Pi footer - one-line status bar showing subscription usage, path, status, model and context.",
5
5
  "keywords": [
6
+ "footer",
7
+ "minimal",
8
+ "usage",
9
+ "limits",
10
+ "status-bar",
11
+ "one-line",
6
12
  "pi-package",
7
13
  "pi-extension",
8
- "pi",
9
- "footer",
10
- "minimal"
14
+ "pi"
11
15
  ],
12
16
  "homepage": "https://github.com/Ryu-CZ/pi-minimal-footer#readme",
13
17
  "bugs": {