pi-minimal-footer 0.2.0 → 0.3.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 +30 -1
- package/README.md +40 -116
- package/VISION.md +106 -0
- package/extensions/index.ts +51 -32
- package/extensions/lib/usage-limits.ts +38 -13
- package/media/github-preview.png +0 -0
- package/media/preview.png +0 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.1] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Keep quota requests and timers stopped after another extension removes or replaces the footer, even when the model changes; explicit reinstallation restores polling
|
|
15
|
+
- Use all spare columns for responsive quota bars when the left group is absent
|
|
16
|
+
- Fall back to default settings when the JSON root is null, a scalar, or an array
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Clarify renderer ownership and layout budgeting with rationale comments; isolate reset-label formatting and name shared bar-size limits
|
|
21
|
+
|
|
22
|
+
## [0.3.0] - 2026-10-06
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `minFooter.powerlineSeparator` (default `true`); set to `false` for a single-space fallback when the terminal font lacks Powerline glyphs
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Keep README focused on installation and daily use; move design constraints, developer guidance, and provider details into `VISION.md`
|
|
31
|
+
- Separate directory and git branch with ``; render each extension status as its own `` tab, without footer-added parentheses or pipes
|
|
32
|
+
- Group model, context, and quota with spaced ` · ` separators
|
|
33
|
+
- Join the quota bar and reset label without an intervening space
|
|
34
|
+
- Show reset labels as local `↻HH:mm` through 24 hours, remaining whole days/hours above 24 hours (e.g., `↻1d8h`), and whole days above 10 days (e.g., `↻12d`)
|
|
35
|
+
- Grow the quota bar from five to ten cells using spare footer columns, improving steps from 2.5% to 1.25% without shortening other fields; retain the smaller fallback on very narrow terminals
|
|
36
|
+
|
|
10
37
|
## [0.2.0] - 2026-10-05
|
|
11
38
|
|
|
12
39
|
### Added
|
|
@@ -114,7 +141,9 @@ First npm-ready release.
|
|
|
114
141
|
|
|
115
142
|
- Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
|
|
116
143
|
|
|
117
|
-
[Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.
|
|
144
|
+
[Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.1...HEAD
|
|
145
|
+
[0.3.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.0...v0.3.1
|
|
146
|
+
[0.3.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.2.0...v0.3.0
|
|
118
147
|
[0.2.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...v0.2.0
|
|
119
148
|
[0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
|
|
120
149
|
[0.1.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.1...v0.1.2
|
package/README.md
CHANGED
|
@@ -1,142 +1,43 @@
|
|
|
1
1
|
# pi-minimal-footer
|
|
2
2
|
|
|
3
3
|
<p>
|
|
4
|
-
<a href="https://www.npmjs.com/package/pi-minimal-footer">
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
<a href="https://
|
|
8
|
-
<img src="https://img.shields.io/npm/dt/pi-minimal-footer" alt="npm downloads">
|
|
9
|
-
</a>
|
|
10
|
-
<a href="LICENSE">
|
|
11
|
-
<img src="https://img.shields.io/npm/l/pi-minimal-footer" alt="license">
|
|
12
|
-
</a>
|
|
13
|
-
<a href="https://pi.dev/packages/pi-minimal-footer">
|
|
14
|
-
<img src="https://img.shields.io/badge/pi-package-1a1a2e" alt="pi package">
|
|
15
|
-
</a>
|
|
4
|
+
<a href="https://www.npmjs.com/package/pi-minimal-footer"><img src="https://img.shields.io/npm/v/pi-minimal-footer" alt="npm version"></a>
|
|
5
|
+
<a href="https://www.npmjs.com/package/pi-minimal-footer"><img src="https://img.shields.io/npm/dt/pi-minimal-footer" alt="npm downloads"></a>
|
|
6
|
+
<a href="LICENSE"><img src="https://img.shields.io/npm/l/pi-minimal-footer" alt="license"></a>
|
|
7
|
+
<a href="https://pi.dev/packages/pi-minimal-footer"><img src="https://img.shields.io/badge/pi-package-1a1a2e" alt="pi package"></a>
|
|
16
8
|
</p>
|
|
17
9
|
|
|
18
|
-
An opinionated,
|
|
10
|
+
An opinionated, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
|
|
19
11
|
|
|
20
|
-
```
|
|
21
|
-
~/git/
|
|
12
|
+
```text
|
|
13
|
+
~/git/project main 🧠 Karpathy 🪽 Icarus model · 42/200k · ⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h
|
|
22
14
|
```
|
|
23
15
|
|
|
24
|
-
|
|
16
|
+
Path and branch on the left; extension statuses, model, context, and provider quota on the right. Shrinks to fit narrow terminals. Interactive terminal UI only.
|
|
17
|
+
|
|
18
|
+

|
|
25
19
|
|
|
26
20
|
## Install
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
Requires Node.js >=22.19.0.
|
|
29
23
|
|
|
30
24
|
```bash
|
|
31
|
-
# From npm (recommended)
|
|
32
25
|
pi install npm:pi-minimal-footer
|
|
33
|
-
|
|
34
|
-
# From git
|
|
26
|
+
# Or install from source:
|
|
35
27
|
pi install git:github.com/Ryu-CZ/pi-minimal-footer
|
|
36
|
-
|
|
37
|
-
# Manual — copy into your extensions directory
|
|
38
|
-
cp -r extensions/* ~/.pi/agent/extensions/
|
|
39
|
-
|
|
40
|
-
# Development — symlink for live edits
|
|
41
|
-
ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Development
|
|
45
|
-
|
|
46
|
-
Requires Node.js >=22.19.0. Run `npm ci`, `npm run check`, and `npm test`. Pi loads TypeScript directly; no build is needed. For an isolated local preview, run `pi --no-extensions -e ./extensions/index.ts`. After source edits, use `/reload` in Pi. Tests use Pi 1.0.3 and make no provider requests.
|
|
47
|
-
|
|
48
|
-
## Features
|
|
49
|
-
|
|
50
|
-
- **Working directory** — home abbreviated as `~`; paths outside home remain absolute
|
|
51
|
-
- **Extension statuses** — text reported by extensions through Pi's status API (`showSkills` retains its existing setting name; it does not discover installed skills)
|
|
52
|
-
- **Git branch** — current branch name
|
|
53
|
-
- **Model** — active model ID
|
|
54
|
-
- **Context usage** — tokens used / context window (e.g., `12/128k`). After compaction, Pi reports usage as unknown (`?`) until a subsequent model response.
|
|
55
|
-
- **Provider quota** — the right end shows the selected provider's shortest available quota as a Braille bar and local reset time in 24-hour `HH:mm` format, when reported.
|
|
56
|
-
|
|
57
|
-
The directory and git branch stay together on the left. Extension statuses, model, context usage, and quota stay on the right, with extra spacing before the quota. Statuses and location are muted; the model is bold.
|
|
58
|
-
|
|
59
|
-
On narrow terminals, the directory and statuses shorten or disappear, then the branch disappears. The quota bar shrinks before model/context is shortened, preserving the reset time. The footer always uses one line.
|
|
60
|
-
|
|
61
|
-
The footer appears only in Pi's interactive terminal UI, not in print, JSON, or RPC modes.
|
|
62
|
-
|
|
63
|
-
### Provider usage limits
|
|
64
|
-
|
|
65
|
-
The footer displays only the selected provider's shortest available quota window, typically five hours. If only a weekly or monthly quota is available, it displays that quota. This measures account quota used, separately from the model's context usage (`12/128k`).
|
|
66
|
-
|
|
67
|
-
| Provider | Quota selection |
|
|
68
|
-
|---|---|
|
|
69
|
-
| OpenAI Codex (`openai-codex`) | Shortest primary/secondary window |
|
|
70
|
-
| Claude (`anthropic`, OAuth) | Five-hour window, or weekly if that is all that is reported |
|
|
71
|
-
| GitHub Copilot (`github-copilot`) | Limited premium/chat quota; unlimited buckets are hidden |
|
|
72
|
-
| Gemini CLI (`google-gemini-cli`) | Selected model's quota, with Pro/Flash family fallback |
|
|
73
|
-
| MiniMax (`minimax`, `minimax-cn`) | General text bucket's shortest interval/weekly window |
|
|
74
|
-
| Kimi Coding (`kimi-coding`) | Shortest reported window, or weekly fallback |
|
|
75
|
-
| OpenCode Go (`opencode-go`) | Shortest rolling/weekly/monthly window |
|
|
76
|
-
|
|
77
|
-
The adapters reuse the selected provider's authentication from Pi. Claude subscription quota requires OAuth; ordinary Claude API keys do not expose it. Copilot's quota endpoint uses the GitHub login token from Pi's configured agent directory or `COPILOT_GITHUB_TOKEN`. Gemini CLI needs a configured provider/model; Pi 1.0.3 does not include that provider in its built-in catalog.
|
|
78
|
-
|
|
79
|
-
The usage segment contains only a five-cell Braille bar and its reset time:
|
|
80
|
-
|
|
81
|
-
```text
|
|
82
|
-
⣿⣶⠀⠀⠀ ↻16:40
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
This example shows 35% used. `↻16:40` means the quota resets at 16:40 in your local timezone: an absolute 24-hour time, not a countdown. No provider name, window label, percentage, or date is displayed.
|
|
86
|
-
|
|
87
|
-
If the provider does not report a reset time, only the bar is shown:
|
|
88
|
-
|
|
89
|
-
```text
|
|
90
|
-
⣿⣶⠀⠀⠀
|
|
91
28
|
```
|
|
92
29
|
|
|
93
|
-
Each cell uses comic-style dot shading, starting from the bottom:
|
|
94
|
-
|
|
95
|
-
| Cell | Fill |
|
|
96
|
-
|---|---|
|
|
97
|
-
| `⠀` | Empty |
|
|
98
|
-
| `⡀` | One-eighth |
|
|
99
|
-
| `⣀` | Quarter |
|
|
100
|
-
| `⣄` | Three-eighths |
|
|
101
|
-
| `⣤` | Half |
|
|
102
|
-
| `⣦` | Five-eighths |
|
|
103
|
-
| `⣶` | Three-quarter |
|
|
104
|
-
| `⣷` | Seven-eighths |
|
|
105
|
-
| `⣿` | Full |
|
|
106
|
-
|
|
107
|
-
Each step adds one dot from the bottom upward. Five cells provide 2.5% steps, rounded to the nearest step; only the last partially filled cell uses an intermediate state. Filled cells are green below 85%, amber from 85%, and red from 92%. On narrow terminals the bar shrinks to preserve the reset time, so its steps become coarser.
|
|
108
|
-
|
|
109
|
-
Usage refreshes from Codex response headers/stream events and Claude quota response headers when available. Otherwise, the footer checks the selected provider's usage endpoint on startup or provider switch, then every four minutes without a fresh response update. It also checks when a reported reset time arrives. These checks make no model requests.
|
|
110
|
-
|
|
111
|
-
Partial response updates are merged with cached windows. A weekly-only update keeps the cached five-hour quota visible and does not postpone its refresh. Authentication, fetching, and response parsing share a five-second timeout; a stalled lookup or body read releases the request and allows the next scheduled attempt. Late results cannot restore a previous provider's quota.
|
|
112
|
-
|
|
113
|
-
Codex endpoint responses can supply either an absolute reset timestamp or a relative reset delay. Kimi quotas accept either used or remaining counts, including week/month durations. Invalid optional reset metadata leaves the bar visible without a time.
|
|
114
|
-
|
|
115
|
-
Switching providers immediately clears the previous quota, cancels its requests and timers, and ignores late results. Switching back fetches fresh data. Gemini model switches also refresh the model-specific quota. A previous provider's bar never remains visible while the new provider loads.
|
|
116
|
-
|
|
117
|
-
Local models, unsupported providers, and sessions without a selected model show no usage segment and make no usage requests. Missing credentials or a successful usage response without an applicable quota hide the segment, including any previously cached bar. Zero usage still displays an empty bar, with its reset time when available.
|
|
118
|
-
|
|
119
|
-
If a refresh fails temporarily or returns malformed quota data, the current provider's last known bar is dimmed until fresh data arrives; without cached data, the segment stays hidden. Explicit empty or unlimited quota responses clear the cached bar. Requests and timers also stop when the footer is disabled, disposed, or the session shuts down. Custom proxy endpoints are not polled. Live quota availability depends on each provider's endpoint; unavailable endpoints stay silent.
|
|
120
|
-
|
|
121
|
-
The adapters have automated parsing and lifecycle coverage, but authenticated provider endpoints have not been tested live. In particular, OpenCode Go's bearer-token response format and Copilot's public quota endpoint and enterprise compatibility remain unverified. The comparison with [mtrojnar/pi-usage](https://github.com/mtrojnar/pi-usage) informed the timeout, validation, and partial-update handling; its OpenCode implementation uses an authenticated dashboard instead.
|
|
122
|
-
|
|
123
|
-
### Credits
|
|
124
|
-
|
|
125
|
-
Honorable mention and thanks to **Can Celik (@ogulcancelik)**: the provider usage-fetching and quota-parsing logic was **copied and adapted from [@ogulcancelik/pi-minimal-footer](https://pi.dev/packages/@ogulcancelik/pi-minimal-footer)**. This footer keeps its minimal layout, five-cell bottom-up Braille display, and four-minute refresh behavior. The upstream MIT copyright and license are retained in [extensions/lib/LICENSE](extensions/lib/LICENSE).
|
|
126
|
-
|
|
127
30
|
## Commands
|
|
128
31
|
|
|
129
|
-
| Command |
|
|
32
|
+
| Command | Action |
|
|
130
33
|
|---|---|
|
|
131
|
-
| `/minfooter` | Toggle
|
|
34
|
+
| `/minfooter` | Toggle footer |
|
|
132
35
|
| `/minfooter on` | Enable |
|
|
133
36
|
| `/minfooter off` | Disable |
|
|
134
37
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
## Configuration
|
|
38
|
+
## Settings
|
|
138
39
|
|
|
139
|
-
|
|
40
|
+
Edit `minFooter` in Pi's agent `settings.json` (normally `~/.pi/agent/settings.json`). All defaults are shown below. Apply edits with `/reload` or `/minfooter on`.
|
|
140
41
|
|
|
141
42
|
```json
|
|
142
43
|
{
|
|
@@ -146,7 +47,30 @@ Settings live in Pi's configured agent directory, in `settings.json` under the `
|
|
|
146
47
|
"showSkills": true,
|
|
147
48
|
"showPath": true,
|
|
148
49
|
"showModel": true,
|
|
149
|
-
"showContext": true
|
|
50
|
+
"showContext": true,
|
|
51
|
+
"powerlineSeparator": true
|
|
150
52
|
}
|
|
151
53
|
}
|
|
152
54
|
```
|
|
55
|
+
|
|
56
|
+
- `showSkills` shows **extension status text**, not installed skills. The footer does not add the example statuses itself.
|
|
57
|
+
- 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.
|
|
58
|
+
- Context `42/200k` means tokens used / context window. `?` means Pi has not reported usage yet, including immediately after compaction.
|
|
59
|
+
|
|
60
|
+
## Reading quota
|
|
61
|
+
|
|
62
|
+
`⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h` shows **account quota used**, not context usage.
|
|
63
|
+
|
|
64
|
+
- Bar: 5–10 cells, expanding into spare space; green below 85%, amber from 85%, red from 92%. Dim means cached after a refresh failure. Very narrow terminals may show fewer cells or only the reset label.
|
|
65
|
+
- 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.
|
|
66
|
+
- Shows the shortest available quota window for the selected provider. Missing credentials, unsupported/local models, or absent limits hide quota. Custom proxy endpoints are not polled.
|
|
67
|
+
|
|
68
|
+
Supported 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 quota. Provider availability varies; authenticated endpoints remain unverified live.
|
|
69
|
+
|
|
70
|
+
## Development & design
|
|
71
|
+
|
|
72
|
+
See [VISION.md](VISION.md) for design constraints, local development, provider details, and verification limits. Release history: [CHANGELOG.md](CHANGELOG.md).
|
|
73
|
+
|
|
74
|
+
## Credits
|
|
75
|
+
|
|
76
|
+
Provider usage-fetching and parsing logic **copied and adapted from [Can Celik (@ogulcancelik)'s pi-minimal-footer](https://pi.dev/packages/@ogulcancelik/pi-minimal-footer)**. Upstream MIT attribution is retained in [extensions/lib/LICENSE](extensions/lib/LICENSE).
|
package/VISION.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Vision & developer guide
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
An opinionated footer that answers three questions at a glance: where am I, what is running, and how close am I to a limit? Keep one line, avoid redundant labels, and never exceed the terminal width.
|
|
6
|
+
|
|
7
|
+
The [README](README.md) is the user manual. This document records current design and contributor guidance—not a speculative roadmap.
|
|
8
|
+
|
|
9
|
+
## Layout contract
|
|
10
|
+
|
|
11
|
+
- Directory and git branch form the left group, separated by ``.
|
|
12
|
+
- Each extension status is its own `` tab. Preserve the supplied text; do not invent status labels, parentheses, or pipes.
|
|
13
|
+
- Model, context, and quota form the rightmost group, separated by ` · `. Model identifies the running engine; context describes its usage; quota stays anchored at the right edge.
|
|
14
|
+
- Powerline separators are dim. `powerlineSeparator: false` replaces them with a single space. Font availability cannot be detected reliably.
|
|
15
|
+
- Measure terminal columns with `visibleWidth`, not string length. Preserve ANSI styling and wide-character accounting when truncating.
|
|
16
|
+
|
|
17
|
+
### Space allocation
|
|
18
|
+
|
|
19
|
+
Reserve quota and model/context before allocating location and statuses. Statuses and location may shorten or disappear; on sufficiently narrow terminals, model/context can also shorten or disappear.
|
|
20
|
+
|
|
21
|
+
Lay out other fields with a five-cell quota bar first. Grow the bar only into leftover columns, up to ten cells—never truncate another field solely to enlarge it. At extreme widths, shrink below five cells, show only the reset label, or hide quota if the label cannot fit.
|
|
22
|
+
|
|
23
|
+
Five cells give 2.5% steps; ten give 1.25%. Fill rounds to the nearest eighth-cell step. Only the final partially filled cell uses an intermediate shade:
|
|
24
|
+
|
|
25
|
+
| Cell | Fill |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `⠀` | Empty |
|
|
28
|
+
| `⡀` | One-eighth |
|
|
29
|
+
| `⣀` | Quarter |
|
|
30
|
+
| `⣄` | Three-eighths |
|
|
31
|
+
| `⣤` | Half |
|
|
32
|
+
| `⣦` | Five-eighths |
|
|
33
|
+
| `⣶` | Three-quarter |
|
|
34
|
+
| `⣷` | Seven-eighths |
|
|
35
|
+
| `⣿` | Full |
|
|
36
|
+
|
|
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
|
+
|
|
39
|
+
## Quota contract
|
|
40
|
+
|
|
41
|
+
Display only the selected provider's shortest applicable window. Account quota and model context usage are separate measurements.
|
|
42
|
+
|
|
43
|
+
| Provider | Selection / caveat |
|
|
44
|
+
|---|---|
|
|
45
|
+
| OpenAI Codex (`openai-codex`) | Shortest primary/secondary window; supports absolute reset timestamps and relative reset delays |
|
|
46
|
+
| Claude (`anthropic`, OAuth) | Five-hour window, or weekly fallback; ordinary API keys do not expose subscription quota |
|
|
47
|
+
| GitHub Copilot (`github-copilot`) | Most-used limited quota bucket; unlimited buckets hidden |
|
|
48
|
+
| Gemini CLI (`google-gemini-cli`) | Selected model, with Pro/Flash family fallback; needs a configured provider/model |
|
|
49
|
+
| MiniMax (`minimax`, `minimax-cn`) | Prefer general bucket, then active bucket, then first bucket; shortest interval/weekly window |
|
|
50
|
+
| Kimi Coding (`kimi-coding`) | Shortest available window; accepts used or remaining counts and week/month durations |
|
|
51
|
+
| OpenCode Go (`opencode-go`) | Shortest rolling/weekly/monthly window |
|
|
52
|
+
|
|
53
|
+
Reuse Pi's selected-provider credentials. Copilot uses the GitHub login token from Pi's configured agent directory or `COPILOT_GITHUB_TOKEN`. Pi 1.0.3 does not include Gemini CLI in its built-in model catalog. Do not poll custom proxy endpoints or make model requests to obtain usage.
|
|
54
|
+
|
|
55
|
+
### Refresh and failure behavior
|
|
56
|
+
|
|
57
|
+
- Consume passive Codex response headers/stream events and Claude quota headers when available.
|
|
58
|
+
- Otherwise fetch on startup/provider switch, then every four minutes without a fresh response update; also refresh at the reported reset time.
|
|
59
|
+
- Merge partial windows. A weekly-only signal must not replace a cached shorter window or postpone its refresh.
|
|
60
|
+
- Bound authentication, fetch, and response-body parsing by a shared five-second timeout. Release stalled work so polling can recover.
|
|
61
|
+
- Provider switches clear quota immediately, cancel pending work, and invalidate late results. Gemini model switches also refresh model-specific quota.
|
|
62
|
+
- Missing credentials and explicit empty/unlimited responses clear quota. Temporary authentication, network, or parsing failures retain only the current provider's cached quota, dimmed; without cache, hide it.
|
|
63
|
+
- Invalid optional reset metadata must not discard otherwise valid usage. Zero usage is an empty bar, not an absent quota.
|
|
64
|
+
- Stop requests and timers on disable, disposal, and session shutdown.
|
|
65
|
+
|
|
66
|
+
### Verification limits
|
|
67
|
+
|
|
68
|
+
Parsing and lifecycle behavior have automated coverage. Authenticated provider endpoints have **not** been tested live. OpenCode Go's bearer-token response format and Copilot's public endpoint/enterprise compatibility remain unverified.
|
|
69
|
+
|
|
70
|
+
The comparison with [mtrojnar/pi-usage](https://github.com/mtrojnar/pi-usage) informed timeout, validation, and partial-update handling; its OpenCode implementation uses an authenticated dashboard rather than this adapter's bearer-token endpoint.
|
|
71
|
+
|
|
72
|
+
## Local development
|
|
73
|
+
|
|
74
|
+
Requires Node.js >=22.19.0. Tests currently use Pi 1.0.3 and mock provider requests.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm ci
|
|
78
|
+
npm run check
|
|
79
|
+
npm test
|
|
80
|
+
|
|
81
|
+
# Isolated interactive preview; Pi loads TypeScript directly, no build needed:
|
|
82
|
+
pi --no-extensions -e ./extensions/index.ts
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Use `/reload` after edits. For a persistent development install:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For a manual install, copy `extensions/*` into `~/.pi/agent/extensions/`.
|
|
92
|
+
|
|
93
|
+
### Code map
|
|
94
|
+
|
|
95
|
+
- `extensions/index.ts`: settings, state refresh, layout, footer lifecycle, and `/minfooter`.
|
|
96
|
+
- `extensions/lib/usage-limits.ts`: quota refresh, cache, cancellation, and bar/reset rendering.
|
|
97
|
+
- `extensions/lib/quota-providers.ts`: authentication, endpoint selection, and response normalization.
|
|
98
|
+
- `tests/`: loader-based lifecycle/layout regression tests and quota parsing tests.
|
|
99
|
+
|
|
100
|
+
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
|
+
|
|
102
|
+
### Checking changes
|
|
103
|
+
|
|
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. Quota 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.
|
|
105
|
+
|
|
106
|
+
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.
|
package/extensions/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal footer — replaces pi's default footer with a clean status line:
|
|
3
3
|
*
|
|
4
|
-
* ~/path/to/dir
|
|
4
|
+
* ~/path/to/dir main status1 status2 sonnet · 12/128k · ⣿⣶⠀⠀⠀↻16:40
|
|
5
5
|
*
|
|
6
6
|
* Settings are persisted in the agent directory (usually ~/.pi/agent)
|
|
7
7
|
* settings.json under "minFooter".
|
|
@@ -17,7 +17,7 @@ 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 { UsageLimits } from "./lib/usage-limits.js";
|
|
20
|
+
import { BASE_BAR_CELLS, MAX_BAR_CELLS, UsageLimits } from "./lib/usage-limits.js";
|
|
21
21
|
|
|
22
22
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
23
23
|
|
|
@@ -29,6 +29,7 @@ interface Settings {
|
|
|
29
29
|
showPath?: boolean;
|
|
30
30
|
showModel?: boolean;
|
|
31
31
|
showContext?: boolean;
|
|
32
|
+
powerlineSeparator?: boolean;
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -41,6 +42,7 @@ const DEFAULT_SETTINGS: FooterSettings = {
|
|
|
41
42
|
showPath: true,
|
|
42
43
|
showModel: true,
|
|
43
44
|
showContext: true,
|
|
45
|
+
powerlineSeparator: true,
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
function settingsPath(): string {
|
|
@@ -50,7 +52,10 @@ function settingsPath(): string {
|
|
|
50
52
|
function readSettings(): Settings {
|
|
51
53
|
try {
|
|
52
54
|
if (!existsSync(settingsPath())) return {};
|
|
53
|
-
|
|
55
|
+
const settings: unknown = JSON.parse(readFileSync(settingsPath(), "utf-8"));
|
|
56
|
+
// Valid JSON can still be null or a scalar; a broken preference file must not prevent loading.
|
|
57
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return {};
|
|
58
|
+
return settings as Settings;
|
|
54
59
|
} catch {
|
|
55
60
|
return {};
|
|
56
61
|
}
|
|
@@ -63,7 +68,7 @@ function writeSettings(patch: Partial<Settings>): void {
|
|
|
63
68
|
const current = readSettings();
|
|
64
69
|
writeFileSync(path, JSON.stringify({ ...current, ...patch }, null, 2) + "\n");
|
|
65
70
|
} catch {
|
|
66
|
-
//
|
|
71
|
+
// A read-only settings file must not crash the extension.
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -127,56 +132,60 @@ function updateState(ctx: ExtensionContext, state: FooterState): void {
|
|
|
127
132
|
// Keep quota and reset time together; shorten location/statuses before model/context.
|
|
128
133
|
// All segment measurements use visible widths, including ANSI and wide characters.
|
|
129
134
|
|
|
130
|
-
const
|
|
131
|
-
const QUOTA_GAP = "
|
|
135
|
+
const MODEL_GAP = " · ";
|
|
136
|
+
const QUOTA_GAP = " · ";
|
|
132
137
|
const LOCATION_GAP_WIDTH = 3;
|
|
133
|
-
const FULL_QUOTA_WIDTH = 12;
|
|
134
|
-
const COMPACT_QUOTA_WIDTH = 11;
|
|
135
138
|
const MIN_TEXT_WIDTH = 4;
|
|
136
139
|
const MIN_STATUS_WIDTH = 12;
|
|
137
140
|
|
|
138
|
-
function buildLine(
|
|
139
|
-
|
|
141
|
+
function buildLine(
|
|
142
|
+
width: number, path: string, statuses: string, branch: string, model: string, context: string,
|
|
143
|
+
usage: (available: number, maxCells?: number) => string | null,
|
|
144
|
+
statusSeparator: string, locationSeparator: string,
|
|
145
|
+
): string {
|
|
140
146
|
if (width <= 0) return "";
|
|
141
|
-
let core = [model, context].filter(Boolean).join(
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
if (quota && visibleWidth(quota) > availableQuotaWidth) {
|
|
145
|
-
const compactWidth = Math.min(width, Math.max(COMPACT_QUOTA_WIDTH, availableQuotaWidth));
|
|
146
|
-
quota = usage(compactWidth) ?? "";
|
|
147
|
-
}
|
|
147
|
+
let core = [model, context].filter(Boolean).join(MODEL_GAP);
|
|
148
|
+
// Budget the compact bar first; its expanded size must not drive truncation decisions.
|
|
149
|
+
const quota = usage(width) ?? "";
|
|
148
150
|
const quotaGapWidth = quota && core ? QUOTA_GAP.length : 0;
|
|
149
151
|
const coreBudget = Math.max(0, width - visibleWidth(quota) - quotaGapWidth);
|
|
150
152
|
if (quota && coreBudget < MIN_TEXT_WIDTH) {
|
|
151
153
|
core = "";
|
|
152
|
-
quota = usage(Math.min(FULL_QUOTA_WIDTH, width)) ?? "";
|
|
153
154
|
} else if (visibleWidth(core) > coreBudget) {
|
|
154
|
-
const modelGapWidth = model && context ?
|
|
155
|
+
const modelGapWidth = model && context ? MODEL_GAP.length : 0;
|
|
155
156
|
const modelBudget = coreBudget - visibleWidth(context) - modelGapWidth;
|
|
156
157
|
if (modelBudget > 0) {
|
|
157
|
-
core = [truncateToWidth(model, modelBudget, "..."), context].filter(Boolean).join(
|
|
158
|
+
core = [truncateToWidth(model, modelBudget, "..."), context].filter(Boolean).join(MODEL_GAP);
|
|
158
159
|
} else {
|
|
159
160
|
core = truncateToWidth(context || model, coreBudget, "...");
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
const protectedRight = [core, quota].filter(Boolean).join(QUOTA_GAP);
|
|
163
164
|
const branchReservation = branch ? visibleWidth(branch) + LOCATION_GAP_WIDTH : 0;
|
|
164
|
-
const statusBudget = width - visibleWidth(protectedRight) - branchReservation -
|
|
165
|
+
const statusBudget = width - visibleWidth(protectedRight) - branchReservation - visibleWidth(statusSeparator);
|
|
165
166
|
let fittedStatuses = "";
|
|
166
167
|
if (statusBudget >= MIN_STATUS_WIDTH) {
|
|
167
168
|
fittedStatuses = statuses;
|
|
168
169
|
if (visibleWidth(statuses) > statusBudget) {
|
|
169
|
-
fittedStatuses = truncateToWidth(statuses, statusBudget
|
|
170
|
+
fittedStatuses = truncateToWidth(statuses, statusBudget, "...");
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
const leftBudget = Math.max(0, width - visibleWidth(right) -
|
|
173
|
+
let right = [fittedStatuses, protectedRight].filter(Boolean).join(statusSeparator);
|
|
174
|
+
const reservedLocationGapWidth = right ? LOCATION_GAP_WIDTH : 0;
|
|
175
|
+
const leftBudget = Math.max(0, width - visibleWidth(right) - reservedLocationGapWidth);
|
|
175
176
|
const fittedBranch = visibleWidth(branch) <= leftBudget ? branch : "";
|
|
176
|
-
const pathGapWidth = fittedBranch && path ?
|
|
177
|
+
const pathGapWidth = fittedBranch && path ? visibleWidth(locationSeparator) : 0;
|
|
177
178
|
const pathBudget = leftBudget - visibleWidth(fittedBranch) - pathGapWidth;
|
|
178
179
|
const fittedPath = pathBudget >= MIN_TEXT_WIDTH ? truncateToWidth(path, pathBudget, "...") : "";
|
|
179
|
-
const left = [fittedPath, fittedBranch].filter(Boolean).join(
|
|
180
|
+
const left = [fittedPath, fittedBranch].filter(Boolean).join(locationSeparator);
|
|
181
|
+
if (quota) {
|
|
182
|
+
// A dropped left group needs no divider; those columns belong to the quota bar instead.
|
|
183
|
+
const interGroupGapWidth = left && right ? LOCATION_GAP_WIDTH : 0;
|
|
184
|
+
const spareWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - interGroupGapWidth);
|
|
185
|
+
const expandedQuota = usage(visibleWidth(quota) + spareWidth, MAX_BAR_CELLS) ?? quota;
|
|
186
|
+
const expandedCore = [core, expandedQuota].filter(Boolean).join(QUOTA_GAP);
|
|
187
|
+
right = [fittedStatuses, expandedCore].filter(Boolean).join(statusSeparator);
|
|
188
|
+
}
|
|
180
189
|
return left + " ".repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(right))) + right;
|
|
181
190
|
}
|
|
182
191
|
|
|
@@ -190,6 +199,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
190
199
|
let disposeFooter: (() => void) | null = null;
|
|
191
200
|
const usageLimits = new UsageLimits(() => requestRender?.());
|
|
192
201
|
|
|
202
|
+
function selectUsage(ctx: ExtensionContext): void {
|
|
203
|
+
// The preference may remain enabled after another extension replaces our footer.
|
|
204
|
+
// Poll only while we own a live renderer, or model changes can resurrect hidden requests.
|
|
205
|
+
usageLimits.select(ctx, enabled && disposeFooter !== null);
|
|
206
|
+
}
|
|
207
|
+
|
|
193
208
|
function install(ctx: ExtensionContext): void {
|
|
194
209
|
config = readConfig();
|
|
195
210
|
enabled = config.enabled !== false;
|
|
@@ -210,6 +225,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
210
225
|
if (disposed) return;
|
|
211
226
|
disposed = true;
|
|
212
227
|
unsub();
|
|
228
|
+
// A superseded renderer must not stop its replacement's quota polling.
|
|
213
229
|
if (requestRender === request) {
|
|
214
230
|
requestRender = null;
|
|
215
231
|
usageLimits.stop();
|
|
@@ -220,18 +236,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
220
236
|
|
|
221
237
|
return {
|
|
222
238
|
render(width: number): string[] {
|
|
223
|
-
const
|
|
239
|
+
const statuses = config.showSkills
|
|
224
240
|
? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
|
|
225
241
|
: [];
|
|
226
242
|
const branch = config.showGitBranch ? footerData.getGitBranch() : null;
|
|
243
|
+
const statusSeparator = config.powerlineSeparator ? theme.fg("dim", " ") : " ";
|
|
227
244
|
const line = buildLine(
|
|
228
245
|
width,
|
|
229
246
|
config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
|
|
230
|
-
|
|
247
|
+
statuses.length ? statusSeparator + statuses.map((status) => theme.fg("dim", status)).join(statusSeparator) : "",
|
|
231
248
|
branch ? theme.fg("dim", ` ${branch}`) : "",
|
|
232
249
|
config.showModel ? theme.bold(state.model) : "",
|
|
233
250
|
config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
|
|
234
|
-
(available) => usageLimits.line(available, theme),
|
|
251
|
+
(available, maxCells = BASE_BAR_CELLS) => usageLimits.line(available, theme, maxCells),
|
|
252
|
+
statusSeparator,
|
|
253
|
+
config.powerlineSeparator ? theme.fg("dim", " ") : " ",
|
|
235
254
|
);
|
|
236
255
|
return [line];
|
|
237
256
|
},
|
|
@@ -239,7 +258,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
239
258
|
dispose,
|
|
240
259
|
};
|
|
241
260
|
});
|
|
242
|
-
|
|
261
|
+
selectUsage(ctx);
|
|
243
262
|
}
|
|
244
263
|
|
|
245
264
|
/** Cheap refresh: update plain state and request one render. */
|
|
@@ -257,7 +276,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
257
276
|
});
|
|
258
277
|
|
|
259
278
|
pi.on("model_select", async (_event, ctx) => {
|
|
260
|
-
|
|
279
|
+
selectUsage(ctx);
|
|
261
280
|
refresh(ctx);
|
|
262
281
|
});
|
|
263
282
|
|
|
@@ -6,6 +6,29 @@ import { parseUsageWindows, record, shortest, supportedOrigin, usageRequest, win
|
|
|
6
6
|
import type { QuotaWindow } from "./quota-providers.js";
|
|
7
7
|
|
|
8
8
|
const REFRESH_MS = 4 * 60_000;
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
10
|
+
const HOUR_MS = 60 * 60_000;
|
|
11
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
12
|
+
const CELL_STEPS = 8;
|
|
13
|
+
const PARTIAL_CELL_FILLS = ["", "⡀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷"];
|
|
14
|
+
|
|
15
|
+
// Start compact so extra precision never steals space from the other footer fields.
|
|
16
|
+
export const BASE_BAR_CELLS = 5;
|
|
17
|
+
export const MAX_BAR_CELLS = 10;
|
|
18
|
+
|
|
19
|
+
function formatResetLabel(resetAt: number | null, now: number): string {
|
|
20
|
+
if (resetAt === null) return "";
|
|
21
|
+
const remaining = resetAt - now;
|
|
22
|
+
// Clock time loses the day for distant resets; minutes add noise at that scale.
|
|
23
|
+
if (remaining > 10 * DAY_MS) return `↻${Math.floor(remaining / DAY_MS)}d`;
|
|
24
|
+
if (remaining > DAY_MS) {
|
|
25
|
+
const days = Math.floor(remaining / DAY_MS);
|
|
26
|
+
const hours = Math.floor(remaining % DAY_MS / HOUR_MS);
|
|
27
|
+
return `↻${days}d${hours}h`;
|
|
28
|
+
}
|
|
29
|
+
const date = new Date(resetAt);
|
|
30
|
+
return `↻${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
31
|
+
}
|
|
9
32
|
|
|
10
33
|
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
11
34
|
return new Promise((resolve, reject) => {
|
|
@@ -15,7 +38,7 @@ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
15
38
|
};
|
|
16
39
|
signal.addEventListener("abort", abort, { once: true });
|
|
17
40
|
if (signal.aborted) abort();
|
|
18
|
-
//
|
|
41
|
+
// Credential and body promises may ignore abort; observe late failures to avoid unhandled rejections.
|
|
19
42
|
promise.then((value) => {
|
|
20
43
|
signal.removeEventListener("abort", abort);
|
|
21
44
|
resolve(value);
|
|
@@ -179,9 +202,10 @@ export class UsageLimits {
|
|
|
179
202
|
const provider = this.provider!;
|
|
180
203
|
const modelId = model.id;
|
|
181
204
|
const controller = new AbortController();
|
|
205
|
+
// Identity checks below keep late responses from restoring a replaced provider's quota.
|
|
182
206
|
this.request = controller;
|
|
183
207
|
this.attemptedAt = Date.now();
|
|
184
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
208
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
185
209
|
timeout.unref();
|
|
186
210
|
try {
|
|
187
211
|
const request = await abortable(usageRequest(ctx, model), controller.signal);
|
|
@@ -208,18 +232,19 @@ export class UsageLimits {
|
|
|
208
232
|
}
|
|
209
233
|
}
|
|
210
234
|
|
|
211
|
-
line(width: number, theme: Theme): string | null {
|
|
235
|
+
line(width: number, theme: Theme, maxCells = MAX_BAR_CELLS): string | null {
|
|
212
236
|
const window = this.selectedWindow()?.[1];
|
|
213
|
-
if (!this.ctx || !window
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const
|
|
237
|
+
if (!this.ctx || !window) return null;
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
const resetLabel = formatResetLabel(window.resetAt, now);
|
|
240
|
+
if (width < (resetLabel.length || 1)) return null;
|
|
241
|
+
const cells = Math.min(maxCells, Math.max(0, width - resetLabel.length));
|
|
242
|
+
const steps = Math.round(window.used / 100 * cells * CELL_STEPS);
|
|
243
|
+
const filled = "⣿".repeat(Math.floor(steps / CELL_STEPS)) + PARTIAL_CELL_FILLS[steps % CELL_STEPS];
|
|
244
|
+
const empty = "⠀".repeat(cells - Math.ceil(steps / CELL_STEPS));
|
|
245
|
+
const stale = this.stale || (window.resetAt !== null && now >= window.resetAt);
|
|
221
246
|
const color = stale ? "dim" : window.used >= 92 ? "error" : window.used >= 85 ? "warning" : "success";
|
|
222
|
-
const bar = cells ? theme.fg(color, filled) + theme.fg("dim", empty)
|
|
223
|
-
return bar + theme.fg("dim",
|
|
247
|
+
const bar = cells ? theme.fg(color, filled) + theme.fg("dim", empty) : "";
|
|
248
|
+
return bar + theme.fg("dim", resetLabel);
|
|
224
249
|
}
|
|
225
250
|
}
|
package/media/github-preview.png
CHANGED
|
Binary file
|
package/media/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-minimal-footer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"extensions/",
|
|
21
21
|
"LICENSE",
|
|
22
22
|
"README.md",
|
|
23
|
+
"VISION.md",
|
|
23
24
|
"package.json",
|
|
24
25
|
"media/"
|
|
25
26
|
],
|