pi-minimal-footer 0.1.3 → 0.3.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 +46 -1
- package/README.md +42 -49
- package/VISION.md +106 -0
- package/extensions/index.ts +81 -31
- package/extensions/lib/LICENSE +21 -0
- package/extensions/lib/quota-providers.ts +264 -0
- package/extensions/lib/usage-limits.ts +234 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `minFooter.powerlineSeparator` (default `true`); set to `false` for a single-space fallback when the terminal font lacks Powerline glyphs
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Keep README focused on installation and daily use; move design constraints, developer guidance, and provider details into `VISION.md`
|
|
19
|
+
- Separate directory and git branch with ``; render each extension status as its own `` tab, without footer-added parentheses or pipes
|
|
20
|
+
- Group model, context, and quota with spaced ` · ` separators
|
|
21
|
+
- Join the quota bar and reset label without an intervening space
|
|
22
|
+
- 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`)
|
|
23
|
+
- 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
|
|
24
|
+
|
|
25
|
+
## [0.2.0] - 2026-10-05
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Usage limits for the selected provider: OpenAI Codex, Claude OAuth, GitHub Copilot, Gemini CLI, MiniMax (global/CN), Kimi Coding, and OpenCode Go
|
|
30
|
+
- Show the shortest available quota window, including weekly/monthly when no shorter quota exists; omit reset time when the provider does not report it
|
|
31
|
+
- Compact five-cell Braille quota bar with nine bottom-up fill states (`⠀`, `⡀`, `⣀`, `⣄`, `⣤`, `⣦`, `⣶`, `⣷`, `⣿`), 2.5% steps, and green/amber/red usage colors
|
|
32
|
+
- Absolute local reset time marked with `↻` in 24-hour format, e.g. `⣿⣶⠀⠀⠀ ↻16:40`, without provider/window labels or numeric percentages
|
|
33
|
+
- Passive Codex/Claude quota updates from provider responses, with four-minute usage-endpoint fallback and reset-time refresh using Pi's existing credentials
|
|
34
|
+
- Immediate quota clearing and request/timer cancellation on provider switch; ignore late results and dim only the current provider's cached quota after temporary refresh failures
|
|
35
|
+
- Hide usage for local/unsupported models, missing credentials, or absent applicable limits; stop quota work on footer teardown, disable, and session shutdown
|
|
36
|
+
- README examples and documentation for the complete usage feature, its shading, reset time, refresh behavior, and edge cases
|
|
37
|
+
- Honorable mention to Can Celik (@ogulcancelik): provider usage-fetching and quota-parsing logic copied and adapted from [@ogulcancelik/pi-minimal-footer](https://pi.dev/packages/@ogulcancelik/pi-minimal-footer), with upstream MIT attribution retained
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- Group directory and git branch on the left; keep extension statuses, model, context, and quota on the right in one line, with extra spacing before quota
|
|
42
|
+
- Shorten location and statuses on narrow terminals, shrink quota before truncating model/context, and preserve the reset time
|
|
43
|
+
- Simplify quota header parsing and footer width calculations; remove the unused duplicate Claude header parser
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Release stalled quota authentication, fetch, and response-body waits after five seconds so scheduled polling can recover; ignore late results after timeout or provider switch
|
|
48
|
+
- Merge partial Codex/Claude quota signals without replacing a cached shorter window or postponing its four-minute refresh when only longer windows update
|
|
49
|
+
- Preserve dim cached usage after malformed responses, while explicit empty/unlimited quotas clear it; retain valid usage when optional reset metadata is invalid
|
|
50
|
+
- Accept Kimi used-only quotas and week/month durations, and derive Codex reset times from relative endpoint delays when an absolute timestamp is unavailable
|
|
51
|
+
- Clear cached quota when Pi reports missing credentials; retain dim cached usage for temporary authentication failures and recover after credentials are restored
|
|
52
|
+
|
|
10
53
|
## [0.1.3] - 2026-10-05
|
|
11
54
|
|
|
12
55
|
### Added
|
|
@@ -86,7 +129,9 @@ First npm-ready release.
|
|
|
86
129
|
|
|
87
130
|
- Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
|
|
88
131
|
|
|
89
|
-
[Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.
|
|
132
|
+
[Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.0...HEAD
|
|
133
|
+
[0.3.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.2.0...v0.3.0
|
|
134
|
+
[0.2.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...v0.2.0
|
|
90
135
|
[0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
|
|
91
136
|
[0.1.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.1...v0.1.2
|
|
92
137
|
[0.1.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.0...v0.1.1
|
package/README.md
CHANGED
|
@@ -1,75 +1,45 @@
|
|
|
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
|
-
|
|
10
|
+
An opinionated, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
|
|
19
11
|
|
|
12
|
+
```text
|
|
13
|
+
~/git/project main 🧠 Karpathy 🪽 Icarus model · 42/200k · ⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h
|
|
20
14
|
```
|
|
21
|
-
~/path/to/dir (status1 | status2) main sonnet 12/128k
|
|
22
|
-
```
|
|
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
|
+

|
|
19
|
+
|
|
20
|
+
*Screenshot shows an earlier layout; the text preview reflects current source.*
|
|
25
21
|
|
|
26
22
|
## Install
|
|
27
23
|
|
|
28
|
-
|
|
24
|
+
Requires Node.js >=22.19.0.
|
|
29
25
|
|
|
30
26
|
```bash
|
|
31
|
-
# From npm (recommended)
|
|
32
27
|
pi install npm:pi-minimal-footer
|
|
33
|
-
|
|
34
|
-
# From git
|
|
28
|
+
# Or install from source:
|
|
35
29
|
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
30
|
```
|
|
43
31
|
|
|
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
|
-
|
|
56
|
-
On narrow terminals, the footer shrinks or drops the path, then drops extension statuses and the git branch before truncating model/context. The remaining segments stay right-aligned.
|
|
57
|
-
|
|
58
|
-
The footer appears only in Pi's interactive terminal UI, not in print, JSON, or RPC modes.
|
|
59
|
-
|
|
60
32
|
## Commands
|
|
61
33
|
|
|
62
|
-
| Command |
|
|
34
|
+
| Command | Action |
|
|
63
35
|
|---|---|
|
|
64
|
-
| `/minfooter` | Toggle
|
|
36
|
+
| `/minfooter` | Toggle footer |
|
|
65
37
|
| `/minfooter on` | Enable |
|
|
66
38
|
| `/minfooter off` | Disable |
|
|
67
39
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
## Configuration
|
|
40
|
+
## Settings
|
|
71
41
|
|
|
72
|
-
|
|
42
|
+
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`.
|
|
73
43
|
|
|
74
44
|
```json
|
|
75
45
|
{
|
|
@@ -79,7 +49,30 @@ Settings live in Pi's configured agent directory, in `settings.json` under the `
|
|
|
79
49
|
"showSkills": true,
|
|
80
50
|
"showPath": true,
|
|
81
51
|
"showModel": true,
|
|
82
|
-
"showContext": true
|
|
52
|
+
"showContext": true,
|
|
53
|
+
"powerlineSeparator": true
|
|
83
54
|
}
|
|
84
55
|
}
|
|
85
56
|
```
|
|
57
|
+
|
|
58
|
+
- `showSkills` shows **extension status text**, not installed skills. The footer does not add the example statuses itself.
|
|
59
|
+
- 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.
|
|
60
|
+
- Context `42/200k` means tokens used / context window. `?` means Pi has not reported usage yet, including immediately after compaction.
|
|
61
|
+
|
|
62
|
+
## Reading quota
|
|
63
|
+
|
|
64
|
+
`⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h` shows **account quota used**, not context usage.
|
|
65
|
+
|
|
66
|
+
- 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.
|
|
67
|
+
- 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.
|
|
68
|
+
- 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.
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
## Development & design
|
|
73
|
+
|
|
74
|
+
See [VISION.md](VISION.md) for design constraints, local development, provider details, and verification limits. Release history: [CHANGELOG.md](CHANGELOG.md).
|
|
75
|
+
|
|
76
|
+
## Credits
|
|
77
|
+
|
|
78
|
+
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,6 +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
21
|
|
|
21
22
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -28,6 +29,7 @@ interface Settings {
|
|
|
28
29
|
showPath?: boolean;
|
|
29
30
|
showModel?: boolean;
|
|
30
31
|
showContext?: boolean;
|
|
32
|
+
powerlineSeparator?: boolean;
|
|
31
33
|
};
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -40,6 +42,7 @@ const DEFAULT_SETTINGS: FooterSettings = {
|
|
|
40
42
|
showPath: true,
|
|
41
43
|
showModel: true,
|
|
42
44
|
showContext: true,
|
|
45
|
+
powerlineSeparator: true,
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
function settingsPath(): string {
|
|
@@ -123,32 +126,58 @@ function updateState(ctx: ExtensionContext, state: FooterState): void {
|
|
|
123
126
|
|
|
124
127
|
// ── Layout ────────────────────────────────────────────────────────────
|
|
125
128
|
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// 2. drop extension statuses
|
|
129
|
-
// 3. drop the git branch
|
|
130
|
-
// 4. retain model/context as long as possible
|
|
131
|
-
// 5. last resort: truncate the remaining model/context to fit
|
|
132
|
-
//
|
|
133
|
-
// Every returned line is guaranteed to fit `width` (visible-width safe).
|
|
129
|
+
// Keep quota and reset time together; shorten location/statuses before model/context.
|
|
130
|
+
// All segment measurements use visible widths, including ANSI and wide characters.
|
|
134
131
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
const MODEL_GAP = " · ";
|
|
133
|
+
const QUOTA_GAP = " · ";
|
|
134
|
+
const LOCATION_GAP_WIDTH = 3;
|
|
135
|
+
const MIN_TEXT_WIDTH = 4;
|
|
136
|
+
const MIN_STATUS_WIDTH = 12;
|
|
140
137
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
function buildLine(width: number, path: string, statuses: string, branch: string, model: string, context: string,
|
|
139
|
+
usage: (available: number, maxCells?: number) => string | null, statusSeparator: string, locationSeparator: string): string {
|
|
140
|
+
if (width <= 0) return "";
|
|
141
|
+
let core = [model, context].filter(Boolean).join(MODEL_GAP);
|
|
142
|
+
const quota = usage(width) ?? "";
|
|
143
|
+
const quotaGapWidth = quota && core ? QUOTA_GAP.length : 0;
|
|
144
|
+
const coreBudget = Math.max(0, width - visibleWidth(quota) - quotaGapWidth);
|
|
145
|
+
if (quota && coreBudget < MIN_TEXT_WIDTH) {
|
|
146
|
+
core = "";
|
|
147
|
+
} else if (visibleWidth(core) > coreBudget) {
|
|
148
|
+
const modelGapWidth = model && context ? MODEL_GAP.length : 0;
|
|
149
|
+
const modelBudget = coreBudget - visibleWidth(context) - modelGapWidth;
|
|
150
|
+
if (modelBudget > 0) {
|
|
151
|
+
core = [truncateToWidth(model, modelBudget, "..."), context].filter(Boolean).join(MODEL_GAP);
|
|
152
|
+
} else {
|
|
153
|
+
core = truncateToWidth(context || model, coreBudget, "...");
|
|
154
|
+
}
|
|
144
155
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
const protectedRight = [core, quota].filter(Boolean).join(QUOTA_GAP);
|
|
157
|
+
const branchReservation = branch ? visibleWidth(branch) + LOCATION_GAP_WIDTH : 0;
|
|
158
|
+
const statusBudget = width - visibleWidth(protectedRight) - branchReservation - visibleWidth(statusSeparator);
|
|
159
|
+
let fittedStatuses = "";
|
|
160
|
+
if (statusBudget >= MIN_STATUS_WIDTH) {
|
|
161
|
+
fittedStatuses = statuses;
|
|
162
|
+
if (visibleWidth(statuses) > statusBudget) {
|
|
163
|
+
fittedStatuses = truncateToWidth(statuses, statusBudget, "...");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
let right = [fittedStatuses, protectedRight].filter(Boolean).join(statusSeparator);
|
|
167
|
+
const locationGapWidth = right ? LOCATION_GAP_WIDTH : 0;
|
|
168
|
+
const leftBudget = Math.max(0, width - visibleWidth(right) - locationGapWidth);
|
|
169
|
+
const fittedBranch = visibleWidth(branch) <= leftBudget ? branch : "";
|
|
170
|
+
const pathGapWidth = fittedBranch && path ? visibleWidth(locationSeparator) : 0;
|
|
171
|
+
const pathBudget = leftBudget - visibleWidth(fittedBranch) - pathGapWidth;
|
|
172
|
+
const fittedPath = pathBudget >= MIN_TEXT_WIDTH ? truncateToWidth(path, pathBudget, "...") : "";
|
|
173
|
+
const left = [fittedPath, fittedBranch].filter(Boolean).join(locationSeparator);
|
|
174
|
+
if (quota) {
|
|
175
|
+
const spareWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - locationGapWidth);
|
|
176
|
+
const expandedQuota = usage(visibleWidth(quota) + spareWidth, 10) ?? quota;
|
|
177
|
+
const expandedCore = [core, expandedQuota].filter(Boolean).join(QUOTA_GAP);
|
|
178
|
+
right = [fittedStatuses, expandedCore].filter(Boolean).join(statusSeparator);
|
|
179
|
+
}
|
|
180
|
+
return left + " ".repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(right))) + right;
|
|
152
181
|
}
|
|
153
182
|
|
|
154
183
|
// ── Extension ─────────────────────────────────────────────────────────
|
|
@@ -159,13 +188,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
159
188
|
const state: FooterState = { cwd: process.cwd(), model: "no-model", context: "?" };
|
|
160
189
|
let requestRender: (() => void) | null = null;
|
|
161
190
|
let disposeFooter: (() => void) | null = null;
|
|
191
|
+
const usageLimits = new UsageLimits(() => requestRender?.());
|
|
162
192
|
|
|
163
193
|
function install(ctx: ExtensionContext): void {
|
|
164
194
|
config = readConfig();
|
|
165
195
|
enabled = config.enabled !== false;
|
|
166
|
-
if (ctx.mode !== "tui") return;
|
|
196
|
+
if (ctx.mode !== "tui") { usageLimits.stop(); return; }
|
|
167
197
|
|
|
168
198
|
if (!enabled) {
|
|
199
|
+
usageLimits.stop();
|
|
169
200
|
if (disposeFooter) ctx.ui.setFooter(undefined);
|
|
170
201
|
return;
|
|
171
202
|
}
|
|
@@ -179,7 +210,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
210
|
if (disposed) return;
|
|
180
211
|
disposed = true;
|
|
181
212
|
unsub();
|
|
182
|
-
if (requestRender === request)
|
|
213
|
+
if (requestRender === request) {
|
|
214
|
+
requestRender = null;
|
|
215
|
+
usageLimits.stop();
|
|
216
|
+
}
|
|
183
217
|
if (disposeFooter === dispose) disposeFooter = null;
|
|
184
218
|
};
|
|
185
219
|
disposeFooter = dispose;
|
|
@@ -190,20 +224,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
190
224
|
? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
|
|
191
225
|
: [];
|
|
192
226
|
const branch = config.showGitBranch ? footerData.getGitBranch() : null;
|
|
227
|
+
const statusSeparator = config.powerlineSeparator ? theme.fg("dim", " ") : " ";
|
|
193
228
|
const line = buildLine(
|
|
194
229
|
width,
|
|
195
|
-
config.showPath ? abbreviateHome(state.cwd, homedir()) : "",
|
|
196
|
-
skills.length ?
|
|
197
|
-
branch ? ` ${branch}` : "",
|
|
230
|
+
config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
|
|
231
|
+
skills.length ? statusSeparator + skills.map((s) => theme.fg("dim", s)).join(statusSeparator) : "",
|
|
232
|
+
branch ? theme.fg("dim", ` ${branch}`) : "",
|
|
198
233
|
config.showModel ? theme.bold(state.model) : "",
|
|
199
|
-
config.showContext ? theme.bold(state.context) : "",
|
|
234
|
+
config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
|
|
235
|
+
(available, maxCells = 5) => usageLimits.line(available, theme, maxCells),
|
|
236
|
+
statusSeparator,
|
|
237
|
+
config.powerlineSeparator ? theme.fg("dim", " ") : " ",
|
|
200
238
|
);
|
|
201
|
-
return [
|
|
239
|
+
return [line];
|
|
202
240
|
},
|
|
203
241
|
invalidate(): void {},
|
|
204
242
|
dispose,
|
|
205
243
|
};
|
|
206
244
|
});
|
|
245
|
+
usageLimits.select(ctx, enabled);
|
|
207
246
|
}
|
|
208
247
|
|
|
209
248
|
/** Cheap refresh: update plain state and request one render. */
|
|
@@ -215,14 +254,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
215
254
|
// ── Lifecycle events (passive; no footer re-install) ────────────────
|
|
216
255
|
|
|
217
256
|
pi.on("session_start", async (_event, ctx) => {
|
|
257
|
+
usageLimits.stop();
|
|
218
258
|
refresh(ctx);
|
|
219
259
|
install(ctx);
|
|
220
260
|
});
|
|
221
261
|
|
|
222
262
|
pi.on("model_select", async (_event, ctx) => {
|
|
263
|
+
usageLimits.select(ctx, enabled);
|
|
223
264
|
refresh(ctx);
|
|
224
265
|
});
|
|
225
266
|
|
|
267
|
+
pi.on("after_provider_response", (event, ctx) => {
|
|
268
|
+
if (ctx.model) usageLimits.headers(ctx.model.provider, event.headers);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
pi.on("provider_stream_event", (event) => {
|
|
272
|
+
usageLimits.stream(event.provider, event.model, event.data);
|
|
273
|
+
});
|
|
274
|
+
|
|
226
275
|
pi.on("turn_end", async (_event, ctx) => {
|
|
227
276
|
refresh(ctx);
|
|
228
277
|
});
|
|
@@ -240,6 +289,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
240
289
|
});
|
|
241
290
|
|
|
242
291
|
pi.on("session_shutdown", async () => {
|
|
292
|
+
usageLimits.stop();
|
|
243
293
|
disposeFooter?.();
|
|
244
294
|
});
|
|
245
295
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Can Celik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Provider endpoint and parsing logic copied and adapted from:
|
|
2
|
+
// https://pi.dev/packages/@ogulcancelik/pi-minimal-footer
|
|
3
|
+
// Copyright (c) 2025 Can Celik. MIT license: ./LICENSE.
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface QuotaWindow {
|
|
10
|
+
used: number;
|
|
11
|
+
seconds: number | null;
|
|
12
|
+
resetAt: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function record(value: unknown): Record<string, unknown> {
|
|
16
|
+
return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function number(value: unknown): number | undefined {
|
|
20
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
21
|
+
if (typeof value === "string" && value.trim()) {
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
return Number.isFinite(n) ? n : undefined;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resetSeconds(value: unknown): number | undefined {
|
|
29
|
+
const n = typeof value === "string" && !Number.isFinite(Number(value)) ? Date.parse(value) / 1000 : number(value);
|
|
30
|
+
const seconds = n === undefined ? undefined : n > 100_000_000_000 ? n / 1000 : n;
|
|
31
|
+
return seconds !== undefined && seconds > 0 && Number.isFinite(new Date(seconds * 1000).getTime()) ? seconds : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function windowFrom(used: unknown, seconds: unknown, reset: unknown): QuotaWindow | null {
|
|
35
|
+
if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || used > 100) return null;
|
|
36
|
+
if (seconds != null && (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)) return null;
|
|
37
|
+
const validReset = typeof reset === "number" && reset > 0 && Number.isFinite(new Date(reset * 1000).getTime());
|
|
38
|
+
return { used, seconds: typeof seconds === "number" ? seconds : null, resetAt: validReset ? reset * 1000 : null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function shortest(windows: (QuotaWindow | null)[]): QuotaWindow | null {
|
|
42
|
+
return windows.filter((w): w is QuotaWindow => w !== null)
|
|
43
|
+
.sort((a, b) => (a.seconds ?? Infinity) - (b.seconds ?? Infinity) || b.used - a.used)[0] ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ENDPOINTS: Record<string, string> = {
|
|
47
|
+
"openai-codex": "https://chatgpt.com/backend-api/wham/usage",
|
|
48
|
+
anthropic: "https://api.anthropic.com/api/oauth/usage",
|
|
49
|
+
"github-copilot": "https://api.github.com/copilot_internal/user",
|
|
50
|
+
"google-gemini-cli": "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
|
51
|
+
minimax: "https://api.minimax.io/v1/token_plan/remains",
|
|
52
|
+
"minimax-cn": "https://api.minimaxi.com/v1/token_plan/remains",
|
|
53
|
+
"kimi-coding": "https://api.kimi.com/coding/v1/usages",
|
|
54
|
+
"opencode-go": "https://opencode.ai/zen/go/v1/usage",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function supportedOrigin(provider: string, baseUrl: string | undefined): boolean {
|
|
58
|
+
if (!ENDPOINTS[provider] || !baseUrl) return false;
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(baseUrl);
|
|
61
|
+
if (provider === "github-copilot") return url.protocol === "https:" && !url.port &&
|
|
62
|
+
(url.hostname === "githubcopilot.com" || url.hostname.endsWith(".githubcopilot.com"));
|
|
63
|
+
return url.origin === new URL(ENDPOINTS[provider]).origin;
|
|
64
|
+
} catch { return false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function storedOAuth(provider: string): Record<string, unknown> {
|
|
68
|
+
try {
|
|
69
|
+
const entry = record(record(JSON.parse(readFileSync(join(getAgentDir(), "auth.json"), "utf8")))[provider]);
|
|
70
|
+
return entry.type === "oauth" ? entry : {};
|
|
71
|
+
} catch { return {}; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function usageRequest(ctx: ExtensionContext, model: NonNullable<ExtensionContext["model"]>): Promise<{ url: string; init: RequestInit } | null> {
|
|
75
|
+
const provider = model.provider;
|
|
76
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
77
|
+
if (!auth.ok) {
|
|
78
|
+
// Pi exposes missing credentials as a message, alongside transient OAuth errors.
|
|
79
|
+
if (auth.error === `No API key found for "${provider}"`) return null;
|
|
80
|
+
throw new Error(auth.error);
|
|
81
|
+
}
|
|
82
|
+
if (!supportedOrigin(provider, auth.baseUrl ?? model.baseUrl)) return null;
|
|
83
|
+
const authorization = Object.entries(auth.headers ?? {}).find(([name]) => name.toLowerCase() === "authorization")?.[1];
|
|
84
|
+
let token = typeof authorization === "string" ? /^Bearer\s+(.+)$/i.exec(authorization)?.[1] ?? auth.apiKey : auth.apiKey;
|
|
85
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
86
|
+
if (provider === "openai-codex") {
|
|
87
|
+
try {
|
|
88
|
+
const payload = record(JSON.parse(Buffer.from(token?.split(".")[1] ?? "", "base64url").toString("utf8")));
|
|
89
|
+
const id = record(payload["https://api.openai.com/auth"]).chatgpt_account_id;
|
|
90
|
+
if (typeof id !== "string" || !id) return null;
|
|
91
|
+
headers["ChatGPT-Account-Id"] = id;
|
|
92
|
+
} catch { return null; }
|
|
93
|
+
} else if (provider === "anthropic") {
|
|
94
|
+
if (!ctx.modelRegistry.isUsingOAuth?.(model) && !token?.startsWith("sk-ant-oat")) return null;
|
|
95
|
+
headers["anthropic-beta"] = "oauth-2025-04-20";
|
|
96
|
+
} else if (provider === "github-copilot") {
|
|
97
|
+
// Quota API needs the GitHub login token, not the exchanged inference token.
|
|
98
|
+
const refresh = storedOAuth(provider).refresh;
|
|
99
|
+
token = auth.env?.COPILOT_GITHUB_TOKEN ?? process.env.COPILOT_GITHUB_TOKEN ?? (typeof refresh === "string" ? refresh : undefined);
|
|
100
|
+
Object.assign(headers, { "Editor-Version": "vscode/1.96.2", "User-Agent": "GitHubCopilotChat/0.26.7", "X-Github-Api-Version": "2025-04-01" });
|
|
101
|
+
} else if (provider === "google-gemini-cli" && token?.startsWith("{")) {
|
|
102
|
+
const credentials = record(JSON.parse(token));
|
|
103
|
+
token = typeof credentials.token === "string" ? credentials.token : typeof credentials.accessToken === "string" ? credentials.accessToken : undefined;
|
|
104
|
+
}
|
|
105
|
+
if (!token) return null;
|
|
106
|
+
headers.Authorization = `${provider === "github-copilot" ? "token" : "Bearer"} ${token}`;
|
|
107
|
+
const init: RequestInit = { headers };
|
|
108
|
+
if (provider === "google-gemini-cli") {
|
|
109
|
+
init.method = "POST";
|
|
110
|
+
init.body = "{}";
|
|
111
|
+
headers["Content-Type"] = "application/json";
|
|
112
|
+
}
|
|
113
|
+
return { url: ENDPOINTS[provider], init };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
117
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function relativeReset(value: unknown): number | undefined {
|
|
121
|
+
const delay = number(value);
|
|
122
|
+
return delay !== undefined && delay >= 0 ? resetSeconds(Date.now() / 1000 + delay) : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseUsageWindows(provider: string, model: string, payload: unknown): Record<string, QuotaWindow> | null {
|
|
126
|
+
if (!ENDPOINTS[provider]) return null;
|
|
127
|
+
const windows: Record<string, QuotaWindow> = {};
|
|
128
|
+
let recognized = false;
|
|
129
|
+
let malformed = !isObject(payload);
|
|
130
|
+
const data = record(payload);
|
|
131
|
+
const add = (key: string, value: unknown, parse: (w: Record<string, unknown>) => QuotaWindow | null) => {
|
|
132
|
+
if (value === undefined) return;
|
|
133
|
+
recognized = true;
|
|
134
|
+
if (value === null) return;
|
|
135
|
+
const window = isObject(value) ? parse(value) : null;
|
|
136
|
+
if (window) windows[key] = window;
|
|
137
|
+
else malformed = true;
|
|
138
|
+
};
|
|
139
|
+
const finish = () => {
|
|
140
|
+
if (Object.keys(windows).length) return windows;
|
|
141
|
+
if (recognized && !malformed) return null;
|
|
142
|
+
throw new Error(`${provider} usage response malformed`);
|
|
143
|
+
};
|
|
144
|
+
if (provider === "openai-codex") {
|
|
145
|
+
if (Object.hasOwn(data, "rate_limit")) {
|
|
146
|
+
recognized = true;
|
|
147
|
+
if (data.rate_limit !== null && !isObject(data.rate_limit)) malformed = true;
|
|
148
|
+
const limits = record(data.rate_limit);
|
|
149
|
+
for (const [key, name] of [["primary", "primary_window"], ["secondary", "secondary_window"]]) {
|
|
150
|
+
add(key, limits[name], (w) => windowFrom(number(w.used_percent),
|
|
151
|
+
Object.hasOwn(w, "limit_window_seconds") ? number(w.limit_window_seconds) ?? NaN : undefined,
|
|
152
|
+
resetSeconds(w.reset_at) ?? relativeReset(w.reset_after_seconds)));
|
|
153
|
+
}
|
|
154
|
+
if (isObject(data.rate_limit) && Object.keys(limits).length &&
|
|
155
|
+
!Object.hasOwn(limits, "primary_window") && !Object.hasOwn(limits, "secondary_window")) malformed = true;
|
|
156
|
+
}
|
|
157
|
+
return finish();
|
|
158
|
+
}
|
|
159
|
+
if (provider === "anthropic") {
|
|
160
|
+
for (const [name, seconds] of [["five_hour", 18000], ["seven_day", 604800]] as const) {
|
|
161
|
+
add(name, data[name], (w) => windowFrom(number(w.utilization), seconds, resetSeconds(w.resets_at)));
|
|
162
|
+
}
|
|
163
|
+
return finish();
|
|
164
|
+
}
|
|
165
|
+
if (provider === "minimax" || provider === "minimax-cn") {
|
|
166
|
+
const status = number(record(data.base_resp).status_code);
|
|
167
|
+
if (status !== undefined && status !== 0) throw new Error("MiniMax usage unavailable");
|
|
168
|
+
if (Object.hasOwn(data, "model_remains")) {
|
|
169
|
+
recognized = true;
|
|
170
|
+
if (data.model_remains !== null && !Array.isArray(data.model_remains)) malformed = true;
|
|
171
|
+
}
|
|
172
|
+
const buckets = Array.isArray(data.model_remains) ? data.model_remains.map(record) : [];
|
|
173
|
+
const w = buckets.find((b) => b.model_name === "general" && number(b.current_interval_status) === 1)
|
|
174
|
+
?? buckets.find((b) => b.model_name === "general") ?? buckets.find((b) => number(b.current_interval_status) === 1) ?? buckets[0];
|
|
175
|
+
if (w) {
|
|
176
|
+
let supplied = false;
|
|
177
|
+
for (const [key, prefix, startKey, endKey, fallback] of [
|
|
178
|
+
["interval", "current_interval", "start_time", "end_time", 18000],
|
|
179
|
+
["weekly", "current_weekly", "weekly_start_time", "weekly_end_time", 604800],
|
|
180
|
+
] as const) {
|
|
181
|
+
if (!Object.hasOwn(w, `${prefix}_remaining_percent`)) continue;
|
|
182
|
+
supplied = true;
|
|
183
|
+
const remaining = number(w[`${prefix}_remaining_percent`]);
|
|
184
|
+
const start = resetSeconds(w[startKey]), end = resetSeconds(w[endKey]);
|
|
185
|
+
add(key, w, () => windowFrom(remaining === undefined ? undefined : 100 - remaining,
|
|
186
|
+
start !== undefined && end !== undefined ? end - start : fallback, end));
|
|
187
|
+
}
|
|
188
|
+
if (!supplied) malformed = true;
|
|
189
|
+
}
|
|
190
|
+
return finish();
|
|
191
|
+
}
|
|
192
|
+
if (provider === "kimi-coding") {
|
|
193
|
+
const usedPercent = (detail: Record<string, unknown>) => {
|
|
194
|
+
const limit = number(detail.limit);
|
|
195
|
+
const used = Object.hasOwn(detail, "used") ? number(detail.used) : undefined;
|
|
196
|
+
const remaining = number(detail.remaining);
|
|
197
|
+
const count = Object.hasOwn(detail, "used") ? used : limit !== undefined && remaining !== undefined ? limit - remaining : undefined;
|
|
198
|
+
return limit !== undefined && limit > 0 && count !== undefined && count >= 0 && count <= limit ? count / limit * 100 : undefined;
|
|
199
|
+
};
|
|
200
|
+
const units: Record<string, number> = { TIME_UNIT_SECOND: 1, TIME_UNIT_MINUTE: 60, TIME_UNIT_HOUR: 3600, TIME_UNIT_DAY: 86400, TIME_UNIT_WEEK: 604800, TIME_UNIT_MONTH: 2592000 };
|
|
201
|
+
if (Object.hasOwn(data, "limits")) {
|
|
202
|
+
recognized = true;
|
|
203
|
+
if (data.limits !== null && !Array.isArray(data.limits)) malformed = true;
|
|
204
|
+
}
|
|
205
|
+
for (const value of Array.isArray(data.limits) ? data.limits : []) {
|
|
206
|
+
if (!isObject(value)) {
|
|
207
|
+
malformed = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const w = record(value), time = record(w.window), detail = record(w.detail);
|
|
211
|
+
const duration = number(time.duration), unit = typeof time.timeUnit === "string" ? time.timeUnit : "";
|
|
212
|
+
const seconds = duration !== undefined && units[unit] ? duration * units[unit] : NaN;
|
|
213
|
+
add(`${duration}:${unit}`, value, () => windowFrom(usedPercent(detail), seconds, resetSeconds(detail.resetTime)));
|
|
214
|
+
}
|
|
215
|
+
add("weekly", data.usage, (w) => windowFrom(usedPercent(w), 604800, resetSeconds(w.resetTime)));
|
|
216
|
+
return finish();
|
|
217
|
+
}
|
|
218
|
+
if (provider === "opencode-go") {
|
|
219
|
+
for (const [name, seconds] of [["rollingUsage", 18000], ["weeklyUsage", 604800], ["monthlyUsage", 2592000]] as const) {
|
|
220
|
+
add(name, data[name], (w) => windowFrom(number(w.usagePercent), seconds, relativeReset(w.resetInSec)));
|
|
221
|
+
}
|
|
222
|
+
return finish();
|
|
223
|
+
}
|
|
224
|
+
if (provider === "github-copilot") {
|
|
225
|
+
if (Object.hasOwn(data, "quota_snapshots")) {
|
|
226
|
+
recognized = true;
|
|
227
|
+
if (data.quota_snapshots !== null && !isObject(data.quota_snapshots)) malformed = true;
|
|
228
|
+
}
|
|
229
|
+
for (const [name, value] of Object.entries(isObject(data.quota_snapshots) ? data.quota_snapshots : {})) {
|
|
230
|
+
if (record(value).unlimited === true) continue;
|
|
231
|
+
add(name, value, (w) => {
|
|
232
|
+
const remaining = number(w.percent_remaining);
|
|
233
|
+
return windowFrom(remaining === undefined ? undefined : 100 - remaining, 2592000, resetSeconds(data.quota_reset_date_utc));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return finish();
|
|
237
|
+
}
|
|
238
|
+
// Gemini only exposes quotas for the selected model, or a recognizable model family.
|
|
239
|
+
if (Object.hasOwn(data, "buckets")) {
|
|
240
|
+
recognized = true;
|
|
241
|
+
if (data.buckets !== null && !Array.isArray(data.buckets)) malformed = true;
|
|
242
|
+
}
|
|
243
|
+
const buckets = Array.isArray(data.buckets) ? data.buckets.map(record) : [];
|
|
244
|
+
const valid = buckets.filter((b) => {
|
|
245
|
+
const remaining = number(b.remainingFraction);
|
|
246
|
+
const ok = typeof b.modelId === "string" && b.modelId.length > 0 && remaining !== undefined && remaining >= 0 && remaining <= 1;
|
|
247
|
+
if (!ok) malformed = true;
|
|
248
|
+
return ok;
|
|
249
|
+
});
|
|
250
|
+
const hasExact = buckets.some((b) => b.modelId === model);
|
|
251
|
+
const family = model.toLowerCase().includes("flash") ? "flash" : model.toLowerCase().includes("pro") ? "pro" : null;
|
|
252
|
+
const selected = valid.filter((b) => hasExact ? b.modelId === model : family && String(b.modelId).toLowerCase().includes(family));
|
|
253
|
+
for (const b of selected) {
|
|
254
|
+
const identity = `${b.modelId}:${typeof b.tokenType === "string" ? b.tokenType : ""}`;
|
|
255
|
+
let key = identity, duplicate = 2;
|
|
256
|
+
while (Object.hasOwn(windows, key)) key = `${identity}:${duplicate++}`;
|
|
257
|
+
add(key, b, () => windowFrom((1 - number(b.remainingFraction)!) * 100, undefined, resetSeconds(b.resetTime)));
|
|
258
|
+
}
|
|
259
|
+
return finish();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function parseUsage(provider: string, model: string, payload: unknown): QuotaWindow | null {
|
|
263
|
+
return shortest(Object.values(parseUsageWindows(provider, model, payload) ?? {}));
|
|
264
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Usage logic copied and adapted from https://pi.dev/packages/@ogulcancelik/pi-minimal-footer.
|
|
2
|
+
// Copyright (c) 2025 Can Celik. MIT license: ./LICENSE.
|
|
3
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { parseUsageWindows, record, shortest, supportedOrigin, usageRequest, windowFrom } from "./quota-providers.js";
|
|
6
|
+
import type { QuotaWindow } from "./quota-providers.js";
|
|
7
|
+
|
|
8
|
+
const REFRESH_MS = 4 * 60_000;
|
|
9
|
+
|
|
10
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const abort = () => {
|
|
13
|
+
signal.removeEventListener("abort", abort);
|
|
14
|
+
reject(new Error("Usage request aborted"));
|
|
15
|
+
};
|
|
16
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
17
|
+
if (signal.aborted) abort();
|
|
18
|
+
// Observe late failures even after abort has settled the caller's await.
|
|
19
|
+
promise.then((value) => {
|
|
20
|
+
signal.removeEventListener("abort", abort);
|
|
21
|
+
resolve(value);
|
|
22
|
+
}, (error) => {
|
|
23
|
+
signal.removeEventListener("abort", abort);
|
|
24
|
+
reject(error);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function passiveNumber(value: unknown): number {
|
|
30
|
+
if (typeof value === "number") return value;
|
|
31
|
+
if (typeof value === "string" && value.trim()) return Number(value);
|
|
32
|
+
return NaN;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function passiveWindow(used: unknown, seconds: number | undefined, reset: unknown, cached: QuotaWindow | undefined): QuotaWindow | null {
|
|
36
|
+
const window = windowFrom(used, seconds === undefined ? cached?.seconds : seconds, reset);
|
|
37
|
+
// Omitted metadata belongs to the same window only while its duration agrees.
|
|
38
|
+
if (window && reset === undefined && window.seconds === cached?.seconds) window.resetAt = cached.resetAt;
|
|
39
|
+
return window;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fromHeaders(provider: string, headers: Record<string, string>, cached: Record<string, QuotaWindow>): Record<string, QuotaWindow> {
|
|
43
|
+
const windows: Record<string, QuotaWindow> = {};
|
|
44
|
+
let definitions: readonly (readonly [string, string, number?])[];
|
|
45
|
+
if (provider === "openai-codex") {
|
|
46
|
+
definitions = [["primary", "primary"], ["secondary", "secondary"]];
|
|
47
|
+
} else if (provider === "anthropic") {
|
|
48
|
+
definitions = [["five_hour", "5h", 18000], ["seven_day", "7d", 604800]];
|
|
49
|
+
} else {
|
|
50
|
+
return windows;
|
|
51
|
+
}
|
|
52
|
+
const codex = provider === "openai-codex";
|
|
53
|
+
const usedSuffix = codex ? "used-percent" : "utilization";
|
|
54
|
+
const resetSuffix = codex ? "reset-at" : "reset";
|
|
55
|
+
for (const [key, name, defaultSeconds] of definitions) {
|
|
56
|
+
const prefix = codex ? `x-codex-${name}` : `anthropic-ratelimit-unified-${name}`;
|
|
57
|
+
const used = passiveNumber(headers[`${prefix}-${usedSuffix}`]) * (codex ? 1 : 100);
|
|
58
|
+
const durationKey = `${prefix}-window-minutes`;
|
|
59
|
+
const resetKey = `${prefix}-${resetSuffix}`;
|
|
60
|
+
const seconds = Object.hasOwn(headers, durationKey) ? passiveNumber(headers[durationKey]) * 60 : defaultSeconds;
|
|
61
|
+
const reset = Object.hasOwn(headers, resetKey) ? passiveNumber(headers[resetKey]) : undefined;
|
|
62
|
+
const window = passiveWindow(used, seconds, reset, cached[key]);
|
|
63
|
+
if (window) windows[key] = window;
|
|
64
|
+
}
|
|
65
|
+
return windows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fromStream(data: unknown, cached: Record<string, QuotaWindow>): Record<string, QuotaWindow> {
|
|
69
|
+
const event = record(data);
|
|
70
|
+
if (event.type !== "codex.rate_limits") return {};
|
|
71
|
+
// Other metered pools may be specific to a different model.
|
|
72
|
+
const pool = event.metered_limit_name ?? event.limit_name;
|
|
73
|
+
if (pool !== undefined && pool !== "codex") return {};
|
|
74
|
+
const limits = record(event.rate_limits);
|
|
75
|
+
const windows: Record<string, QuotaWindow> = {};
|
|
76
|
+
for (const key of ["primary", "secondary"]) {
|
|
77
|
+
const w = record(limits[key]);
|
|
78
|
+
const window = passiveWindow(passiveNumber(w.used_percent),
|
|
79
|
+
Object.hasOwn(w, "window_minutes") ? passiveNumber(w.window_minutes) * 60 : undefined,
|
|
80
|
+
Object.hasOwn(w, "reset_at") ? passiveNumber(w.reset_at) : undefined, cached[key]);
|
|
81
|
+
if (window) windows[key] = window;
|
|
82
|
+
}
|
|
83
|
+
return windows;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Owns quota requests and timers for the currently displayed footer. */
|
|
87
|
+
export class UsageLimits {
|
|
88
|
+
private provider: string | null = null;
|
|
89
|
+
private selection: string | null = null;
|
|
90
|
+
private ctx: ExtensionContext | null = null;
|
|
91
|
+
private windows: Record<string, QuotaWindow> = {};
|
|
92
|
+
private updatedAt: Record<string, number> = {};
|
|
93
|
+
private stale = false;
|
|
94
|
+
private attemptedAt = 0;
|
|
95
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
96
|
+
private request: AbortController | null = null;
|
|
97
|
+
|
|
98
|
+
constructor(private readonly render: () => void) {}
|
|
99
|
+
|
|
100
|
+
stop(): void {
|
|
101
|
+
this.ctx = null;
|
|
102
|
+
this.provider = null;
|
|
103
|
+
this.selection = null;
|
|
104
|
+
this.windows = {};
|
|
105
|
+
this.updatedAt = {};
|
|
106
|
+
this.stale = false;
|
|
107
|
+
this.attemptedAt = 0;
|
|
108
|
+
this.cancelRequest();
|
|
109
|
+
if (this.timer) clearTimeout(this.timer);
|
|
110
|
+
this.timer = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
select(ctx: ExtensionContext, enabled: boolean): void {
|
|
114
|
+
const model = ctx.model;
|
|
115
|
+
if (!enabled || ctx.mode !== "tui" || !model || !supportedOrigin(model.provider, model.baseUrl)) {
|
|
116
|
+
this.stop();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const selection = model.provider + (model.provider === "google-gemini-cli" ? ":" + model.id : "");
|
|
120
|
+
if (this.selection === selection) { this.ctx = ctx; return; }
|
|
121
|
+
this.stop();
|
|
122
|
+
this.ctx = ctx;
|
|
123
|
+
this.provider = model.provider;
|
|
124
|
+
this.selection = selection;
|
|
125
|
+
void this.poll();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
headers(provider: string, headers: Record<string, string>): void {
|
|
129
|
+
if (provider !== this.provider) return;
|
|
130
|
+
const lower = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
131
|
+
this.accept(fromHeaders(provider, lower, this.windows));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
stream(provider: string, model: string, data: unknown): void {
|
|
135
|
+
if (provider !== "openai-codex" || provider !== this.provider || model !== this.ctx?.model?.id) return;
|
|
136
|
+
this.accept(fromStream(data, this.windows));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private cancelRequest(): void {
|
|
140
|
+
this.request?.abort();
|
|
141
|
+
this.request = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private selectedWindow(): [string, QuotaWindow] | null {
|
|
145
|
+
const window = shortest(Object.values(this.windows));
|
|
146
|
+
return window ? Object.entries(this.windows).find(([, value]) => value === window)! : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private accept(windows: Record<string, QuotaWindow>): void {
|
|
150
|
+
if (!this.ctx || !Object.keys(windows).length) return;
|
|
151
|
+
Object.assign(this.windows, windows);
|
|
152
|
+
for (const key of Object.keys(windows)) this.updatedAt[key] = Date.now();
|
|
153
|
+
const selected = this.selectedWindow()!;
|
|
154
|
+
if (Object.hasOwn(windows, selected[0])) {
|
|
155
|
+
// Only a response for the displayed window supersedes its background refresh.
|
|
156
|
+
this.cancelRequest();
|
|
157
|
+
this.stale = false;
|
|
158
|
+
}
|
|
159
|
+
this.schedule();
|
|
160
|
+
this.render();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private schedule(): void {
|
|
164
|
+
if (this.timer) clearTimeout(this.timer);
|
|
165
|
+
this.timer = null;
|
|
166
|
+
if (!this.ctx || this.request) return;
|
|
167
|
+
const selected = this.selectedWindow();
|
|
168
|
+
let next = Math.max(this.attemptedAt, selected ? this.updatedAt[selected[0]] : 0) + REFRESH_MS;
|
|
169
|
+
const resetAt = selected?.[1].resetAt;
|
|
170
|
+
if (resetAt != null && resetAt > this.attemptedAt) next = Math.min(next, resetAt);
|
|
171
|
+
this.timer = setTimeout(() => { this.timer = null; void this.poll(); }, Math.max(1, next - Date.now()));
|
|
172
|
+
this.timer.unref();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async poll(): Promise<void> {
|
|
176
|
+
const ctx = this.ctx;
|
|
177
|
+
const model = ctx?.model;
|
|
178
|
+
if (!ctx || !model || this.request) return;
|
|
179
|
+
const provider = this.provider!;
|
|
180
|
+
const modelId = model.id;
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
this.request = controller;
|
|
183
|
+
this.attemptedAt = Date.now();
|
|
184
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
185
|
+
timeout.unref();
|
|
186
|
+
try {
|
|
187
|
+
const request = await abortable(usageRequest(ctx, model), controller.signal);
|
|
188
|
+
if (this.request !== controller) return;
|
|
189
|
+
if (controller.signal.aborted) throw new Error("Usage authentication timed out");
|
|
190
|
+
if (!request) { this.windows = {}; this.updatedAt = {}; return; }
|
|
191
|
+
const response = await abortable(fetch(request.url, { ...request.init, signal: controller.signal, redirect: "error" }), controller.signal);
|
|
192
|
+
if (!response.ok) { await abortable(Promise.resolve(response.body?.cancel()), controller.signal); throw new Error("Usage unavailable"); }
|
|
193
|
+
const windows = parseUsageWindows(provider, modelId, await abortable(response.json(), controller.signal));
|
|
194
|
+
if (this.request !== controller) return;
|
|
195
|
+
if (controller.signal.aborted) throw new Error("Usage request timed out");
|
|
196
|
+
this.windows = windows ?? {};
|
|
197
|
+
this.updatedAt = Object.fromEntries(Object.keys(this.windows).map((key) => [key, this.attemptedAt]));
|
|
198
|
+
this.stale = false;
|
|
199
|
+
} catch {
|
|
200
|
+
if (this.request === controller) this.stale = true;
|
|
201
|
+
} finally {
|
|
202
|
+
clearTimeout(timeout);
|
|
203
|
+
if (this.request === controller) {
|
|
204
|
+
this.request = null;
|
|
205
|
+
this.schedule();
|
|
206
|
+
this.render();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
line(width: number, theme: Theme, maxCells = 10): string | null {
|
|
212
|
+
const window = this.selectedWindow()?.[1];
|
|
213
|
+
if (!this.ctx || !window) return null;
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const date = window.resetAt === null ? null : new Date(window.resetAt);
|
|
216
|
+
const remaining = window.resetAt === null ? 0 : window.resetAt - now;
|
|
217
|
+
const day = 24 * 60 * 60 * 1000;
|
|
218
|
+
let time = date ? `↻${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` : "";
|
|
219
|
+
if (remaining > 10 * day) {
|
|
220
|
+
time = `↻${Math.floor(remaining / day)}d`;
|
|
221
|
+
} else if (remaining > day) {
|
|
222
|
+
time = `↻${Math.floor(remaining / day)}d${Math.floor(remaining % day / (60 * 60 * 1000))}h`;
|
|
223
|
+
}
|
|
224
|
+
if (width < (time ? time.length : 1)) return null;
|
|
225
|
+
const cells = Math.min(maxCells, Math.max(0, width - time.length));
|
|
226
|
+
const steps = Math.round(window.used / 100 * cells * 8);
|
|
227
|
+
const filled = "⣿".repeat(Math.floor(steps / 8)) + ["", "⡀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷"][steps % 8];
|
|
228
|
+
const empty = "⠀".repeat(cells - Math.ceil(steps / 8));
|
|
229
|
+
const stale = this.stale || (window.resetAt !== null && now >= window.resetAt);
|
|
230
|
+
const color = stale ? "dim" : window.used >= 92 ? "error" : window.used >= 85 ? "warning" : "success";
|
|
231
|
+
const bar = cells ? theme.fg(color, filled) + theme.fg("dim", empty) : "";
|
|
232
|
+
return bar + theme.fg("dim", time);
|
|
233
|
+
}
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-minimal-footer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
7
7
|
"pi-extension",
|
|
@@ -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
|
],
|