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 +41 -1
- package/README.md +47 -10
- package/VISION.md +35 -5
- package/extensions/index.ts +105 -23
- package/extensions/lib/usage-limits.ts +1 -1
- package/extensions/lib/usage-providers.ts +4 -2
- package/package.json +9 -5
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.
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
44
|
-
- Use the theme's normal text foreground while unsynced, otherwise dim. A count change
|
|
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.
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
326
|
-
const
|
|
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
|
-
//
|
|
333
|
-
branchText ? theme.fg(state.gitSync ? "text" : "dim",
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
443
|
+
updateState(ctx, state);
|
|
362
444
|
install(ctx);
|
|
445
|
+
refreshFooter();
|
|
363
446
|
});
|
|
364
447
|
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "An opinionated minimal footer
|
|
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": {
|