pi-sub-quota 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +97 -2
- package/cache.ts +127 -0
- package/footer.ts +244 -0
- package/index.ts +502 -0
- package/package.json +62 -4
- package/providers/deepseek.ts +59 -0
- package/providers/http.ts +51 -0
- package/providers/index.ts +27 -0
- package/providers/kimi.ts +239 -0
- package/providers/registry.ts +50 -0
- package/providers/types.ts +96 -0
- package/providers/zai.ts +108 -0
- package/settings.ts +41 -0
- package/stats.ts +95 -0
- package/view.ts +340 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ning
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-sub-quota
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See your coding-plan quota — and your prepaid balance — right in the [pi](https://github.com/earendil-works/pi) footer. Nothing to run: while a supported provider is active, the numbers are simply there:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
↑1.2k ↓3.4k 38%/200k (zai-coding-cn 13% 58%) glm-5.3 • max
|
|
7
|
+
↑1.2k ↓3.4k 38%/200k (zai-coding-cn 96% 41m 58%) glm-5.3 • max
|
|
8
|
+
↑960 ↓1.2k 38%/200k (deepseek ¥18.67+¥1.00) deepseek-flash • max
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- The two percentages are your plan's usage windows — the 5-hour one first, then the 7-day one. Green means plenty left, amber means watch out, red means nearly gone.
|
|
12
|
+
- The countdown (`41m`) appears on its own when a window needs attention: it is nearly used up (≥ 80%) or about to reset (within 30 minutes). Quiet windows stay quiet and show only their percentage.
|
|
13
|
+
- DeepSeek has no usage windows; you see your balance instead, split into what you topped up and what was granted (`¥18.67+¥1.00`), exact to the cent. Accounts with several currencies show one balance per currency.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-sub-quota
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Restart pi (or run `/reload`). There is nothing to configure: the extension reuses the credentials pi already stored, so the quota always belongs to the account pi is actually running with. When no supported provider is active — or there is no data yet — the footer stays exactly as pi built it.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
| Command | Effect |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `/quota` | quota for every configured provider, as a panel |
|
|
28
|
+
| `/quota refresh` | fetch fresh numbers for the active provider now |
|
|
29
|
+
| `/quota mode inline\|line\|off` | how — and whether — the footer shows quota |
|
|
30
|
+
| `/quota help` | list options |
|
|
31
|
+
|
|
32
|
+
Everything completes with Tab. `inline` is the default, and your choice is remembered across sessions; `off` restores the built-in footer.
|
|
33
|
+
|
|
34
|
+
## The two display modes
|
|
35
|
+
|
|
36
|
+
**inline** (default) — the numbers live inside the provider prefix:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
(zai-coding-cn 96% 41m 58%)
|
|
40
|
+
│ │ └ 7d window
|
|
41
|
+
│ └ countdown — only while it matters
|
|
42
|
+
└ 5h window
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
When the terminal gets narrow, the balance folds into a single exact total (`¥19.67`), then the quota drops away, then the prefix — your stats are never sacrificed.
|
|
46
|
+
|
|
47
|
+
**line** — a dedicated quota line under the stats, always in full detail:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
~/dev/project (main)
|
|
51
|
+
↑1.2k ↓3.4k 38%/200k (zai-coding-cn) glm-5.3 • max
|
|
52
|
+
(Max) 5h ▓▓▓▓▓▓▓▓▓▓ 96% 41m · 58%
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Every window always shows its label, meter, percentage, and reset, and the line opens with your plan tier (`(Max)`). The meters shrink automatically on narrow terminals.
|
|
56
|
+
|
|
57
|
+
## The /quota panel
|
|
58
|
+
|
|
59
|
+
`/quota` shows all configured providers at once — the active one first:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
zai-coding-cn coding plan · Max · active
|
|
63
|
+
5h ▓▓▓▓░░░░░░ 62% left reset 13:47
|
|
64
|
+
7d ▓▓▓▓▓░░░░░ 55% left reset Fri 15:00
|
|
65
|
+
|
|
66
|
+
deepseek pay as you go · ¥19.67 + ¥1.00 granted · $2.50
|
|
67
|
+
|
|
68
|
+
kimi-coding coding plan · Allegretto · booster ¥100.00/¥200.00
|
|
69
|
+
5h ▓▓▓▓▓▓▓░░░ 30% left reset 17:10
|
|
70
|
+
7d ▓░░░░░░░░░ 89% left reset Mon 00:00
|
|
71
|
+
|
|
72
|
+
/quota refresh · /quota mode line|inline|off · /quota help
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The footer shows how much you have used; the panel shows what is left, as a meter and a percentage, with each reset as a concrete local time (`13:47` today, `Fri 15:00` this week, `Sep 01` later).
|
|
76
|
+
|
|
77
|
+
## Supported providers
|
|
78
|
+
|
|
79
|
+
| Provider | What you see |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| ZAI / GLM coding plans — `zai-coding-cn`, `zai-coding`, GLM and bigmodel aliases | 5h / 7d usage windows with resets, your plan tier (Lite / Pro / Max) |
|
|
82
|
+
| Kimi — `kimi-coding` | 5h / 7d usage windows (+ 30d monthly pool on plans that report one), membership tier (Andante / Moderato / Allegretto) from the account endpoint, and the booster wallet balance (`booster ¥5.00/¥20.00`) once topped up |
|
|
83
|
+
| DeepSeek — `deepseek` | prepaid balance per currency, topped-up + granted split |
|
|
84
|
+
|
|
85
|
+
Custom providers work without configuration too: detection falls back to the model's base URL host (`bigmodel.cn`, `*.z.ai` with a `/coding` path, `*.kimi.com` / `*.kimi.ai` with a `/coding` path, `*.deepseek.com`).
|
|
86
|
+
|
|
87
|
+
## Good to know
|
|
88
|
+
|
|
89
|
+
- Numbers refresh at most every five minutes; countdowns tick every minute without touching the network.
|
|
90
|
+
- A failed refresh never breaks anything: the last numbers stay, marked `~`, until the next success — and `/quota` says why a provider failed (`quota unavailable (HTTP 401)`).
|
|
91
|
+
- Several pi sessions on the same machine share one cache safely.
|
|
92
|
+
- A lapsed Kimi OAuth token is skipped with a note — it refreshes the next time pi itself calls Kimi.
|
|
93
|
+
- Credentials come only from pi's own store; no environment variables are read, and a key can never be sent to the wrong host.
|
|
94
|
+
- While quota is displayed, the footer is a re-implementation of pi's and drops a few rare markers (the virtual-model routing arrow, `xp`, `(auto)`). Everything else renders the same.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT © 2026 Ning. See [LICENSE](LICENSE).
|
package/cache.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk cache for quota snapshots, one file shared by all providers and keyed
|
|
3
|
+
* by provider kind. Parsing and validation are pure so they can be tested;
|
|
4
|
+
* file I/O lives in the extension entry.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type McpToolUsage, PROVIDER_KINDS, type ProviderKind, type QuotaItem, type QuotaSnapshot } from "./providers/index.ts";
|
|
8
|
+
|
|
9
|
+
export const CACHE_VERSION = 2;
|
|
10
|
+
|
|
11
|
+
export interface QuotaCacheFile {
|
|
12
|
+
version: number;
|
|
13
|
+
snapshots: Partial<Record<ProviderKind, QuotaSnapshot>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function emptyCache(): QuotaCacheFile {
|
|
17
|
+
return { version: CACHE_VERSION, snapshots: {} };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Per-tool usage rows must all carry a string code and a finite count. */
|
|
21
|
+
function isValidTools(value: unknown): value is McpToolUsage[] {
|
|
22
|
+
return (
|
|
23
|
+
Array.isArray(value) &&
|
|
24
|
+
value.every(
|
|
25
|
+
(tool) =>
|
|
26
|
+
typeof tool === "object" &&
|
|
27
|
+
tool !== null &&
|
|
28
|
+
typeof (tool as McpToolUsage).modelCode === "string" &&
|
|
29
|
+
typeof (tool as McpToolUsage).usage === "number" &&
|
|
30
|
+
Number.isFinite((tool as McpToolUsage).usage),
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isValidItem(value: unknown): boolean {
|
|
36
|
+
if (typeof value !== "object" || value === null) return false;
|
|
37
|
+
const item = value as Partial<QuotaItem> & { type?: unknown };
|
|
38
|
+
if (item.type === "window") {
|
|
39
|
+
return (
|
|
40
|
+
typeof item.label === "string" &&
|
|
41
|
+
(item.resource === "tokens" || item.resource === "mcp") &&
|
|
42
|
+
typeof item.usedPercent === "number" &&
|
|
43
|
+
Number.isFinite(item.usedPercent) &&
|
|
44
|
+
(item.resetAt === undefined || (typeof item.resetAt === "number" && Number.isFinite(item.resetAt))) &&
|
|
45
|
+
(item.durationMs === undefined || (typeof item.durationMs === "number" && Number.isFinite(item.durationMs))) &&
|
|
46
|
+
(item.tools === undefined || isValidTools(item.tools))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (item.type === "balance") {
|
|
50
|
+
return (
|
|
51
|
+
typeof item.symbol === "string" &&
|
|
52
|
+
typeof item.total === "number" &&
|
|
53
|
+
Number.isFinite(item.total) &&
|
|
54
|
+
typeof item.granted === "number" &&
|
|
55
|
+
Number.isFinite(item.granted)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isValidSnapshot(value: unknown): value is QuotaSnapshot {
|
|
62
|
+
if (typeof value !== "object" || value === null) return false;
|
|
63
|
+
const snap = value as Partial<QuotaSnapshot>;
|
|
64
|
+
return (
|
|
65
|
+
typeof snap.kind === "string" &&
|
|
66
|
+
(PROVIDER_KINDS as readonly string[]).includes(snap.kind) &&
|
|
67
|
+
typeof snap.fetchedAt === "number" &&
|
|
68
|
+
Number.isFinite(snap.fetchedAt) &&
|
|
69
|
+
(snap.planLevel === undefined || typeof snap.planLevel === "string") &&
|
|
70
|
+
Array.isArray(snap.items) &&
|
|
71
|
+
// Item-level corruption must not reach the renderers: a hand-edited or
|
|
72
|
+
// foreign-written cache file is user-visible, and view code trusts item
|
|
73
|
+
// shapes (calls .map/.charAt/.padEnd on their fields).
|
|
74
|
+
snap.items.every(isValidItem)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse cache file text. Tolerates a missing file, invalid JSON, or an
|
|
80
|
+
* unexpected shape by returning an empty cache instead of throwing.
|
|
81
|
+
*/
|
|
82
|
+
export function parseCache(text: string | undefined): QuotaCacheFile {
|
|
83
|
+
if (!text) return emptyCache();
|
|
84
|
+
try {
|
|
85
|
+
const raw: unknown = JSON.parse(text);
|
|
86
|
+
if (typeof raw !== "object" || raw === null) return emptyCache();
|
|
87
|
+
const file = raw as { version?: unknown; snapshots?: unknown };
|
|
88
|
+
if (file.version !== CACHE_VERSION || typeof file.snapshots !== "object" || file.snapshots === null) {
|
|
89
|
+
return emptyCache();
|
|
90
|
+
}
|
|
91
|
+
const snapshots: Partial<Record<ProviderKind, QuotaSnapshot>> = {};
|
|
92
|
+
for (const [kind, snapshot] of Object.entries(file.snapshots)) {
|
|
93
|
+
if ((PROVIDER_KINDS as readonly string[]).includes(kind) && isValidSnapshot(snapshot)) {
|
|
94
|
+
snapshots[kind as ProviderKind] = snapshot;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { version: CACHE_VERSION, snapshots };
|
|
98
|
+
} catch {
|
|
99
|
+
return emptyCache();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function serializeCache(cache: QuotaCacheFile): string {
|
|
104
|
+
return `${JSON.stringify(cache, null, "\t")}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* True when the text is a cache file written by a newer format. Such a file
|
|
109
|
+
* owns the data on disk: rewriting it as this version would silently discard
|
|
110
|
+
* whatever that format stored, so writers must leave it alone.
|
|
111
|
+
*/
|
|
112
|
+
export function isNewerCacheFile(text: string | undefined): boolean {
|
|
113
|
+
if (!text) return false;
|
|
114
|
+
try {
|
|
115
|
+
const raw: unknown = JSON.parse(text);
|
|
116
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
117
|
+
const version = (raw as { version?: unknown }).version;
|
|
118
|
+
return typeof version === "number" && Number.isFinite(version) && version > CACHE_VERSION;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when the snapshot was fetched within the TTL window. */
|
|
125
|
+
export function freshSnapshot(snapshot: QuotaSnapshot | undefined, now: number, ttlMs: number): snapshot is QuotaSnapshot {
|
|
126
|
+
return snapshot !== undefined && now - snapshot.fetchedAt < ttlMs;
|
|
127
|
+
}
|
package/footer.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom footer with two alert-driven quota display modes. Quiet windows
|
|
3
|
+
* show only their percentage; a reset countdown appears only when a window
|
|
4
|
+
* is alerting — usage ≥ USAGE_ALERT_PERCENT or a reset within RESET_ALERT_MS.
|
|
5
|
+
*
|
|
6
|
+
* Mode "line" keeps a dedicated quota line under the stats whenever a
|
|
7
|
+
* snapshot exists, with every window in full detail — label, meter,
|
|
8
|
+
* percentage, and reset countdown — because the space is there:
|
|
9
|
+
*
|
|
10
|
+
* ↑1.2k ↓3.4k 38%/200k (zai-coding-cn) glm-5.3 • max
|
|
11
|
+
* (Max) 5h ▓▓▓▓▓▓▓▓▓▓ 96% 41m · 58%
|
|
12
|
+
*
|
|
13
|
+
* Mode "inline" keeps the built-in layout and carries the quota inside the
|
|
14
|
+
* provider prefix, which keeps the real provider id exactly like the built-in
|
|
15
|
+
* footer:
|
|
16
|
+
*
|
|
17
|
+
* ~/dev/project (main)
|
|
18
|
+
* ↑1.2k ↓3.4k 38%/200k (zai-coding-cn 96% 41m 58%) glm-5.3 • max
|
|
19
|
+
*
|
|
20
|
+
* When width is tight the quota drops from the prefix entirely (back to the
|
|
21
|
+
* built-in `(zai-coding-cn)`), then the prefix drops before the model info.
|
|
22
|
+
*
|
|
23
|
+
* Known deviations while the custom footer is installed: no virtual-model
|
|
24
|
+
* routing arrow, no experimental "xp" marker, no "(auto)" compaction suffix,
|
|
25
|
+
* and "(sub)" only for kimi-coding (OAuth subscription detection is not
|
|
26
|
+
* reachable from extensions). Everything else renders the same way. With no
|
|
27
|
+
* snapshot data at all, the built-in footer stays untouched.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
32
|
+
import type { QuotaSnapshot } from "./providers/index.ts";
|
|
33
|
+
import type { QuotaMode } from "./settings.ts";
|
|
34
|
+
import { computeSessionStats, formatCwd, formatTokens, type SessionStats } from "./stats.ts";
|
|
35
|
+
import { buildQuotaInline, buildQuotaLine, type QuotaStyle } from "./view.ts";
|
|
36
|
+
|
|
37
|
+
/** The factory `ctx.ui.setFooter` expects, derived from its signature so TUI/Theme types need no import. */
|
|
38
|
+
export type FooterFactory = NonNullable<Parameters<ExtensionContext["ui"]["setFooter"]>[0]>;
|
|
39
|
+
|
|
40
|
+
/** Meter width for the "line" mode, keyed by minimum terminal width. */
|
|
41
|
+
const METER_WIDTHS: readonly { minWidth: number; cells: number }[] = [
|
|
42
|
+
{ minWidth: 80, cells: 10 },
|
|
43
|
+
{ minWidth: 60, cells: 6 },
|
|
44
|
+
{ minWidth: 48, cells: 4 },
|
|
45
|
+
{ minWidth: 0, cells: 0 },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Mirrors the built-in footer's status sanitizer: collapse whitespace and control characters. */
|
|
49
|
+
function sanitizeStatusText(text: string): string {
|
|
50
|
+
return text
|
|
51
|
+
.replace(/[\r\n\t]/g, " ")
|
|
52
|
+
.replace(/ +/g, " ")
|
|
53
|
+
.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface StatsCache {
|
|
57
|
+
entryCount: number;
|
|
58
|
+
leafId: string | null;
|
|
59
|
+
/** Model object identity: a different model can carry a different context window. */
|
|
60
|
+
model: ExtensionContext["model"];
|
|
61
|
+
/** Cached ctx.getContextUsage(): it rebuilds the session projection (O(entries)) per call. */
|
|
62
|
+
contextUsage: ReturnType<ExtensionContext["getContextUsage"]>;
|
|
63
|
+
stats: SessionStats;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface QuotaFooterSource {
|
|
67
|
+
/** Latest in-memory snapshot, refreshed by the extension entry. */
|
|
68
|
+
getSnapshot(): QuotaSnapshot | undefined;
|
|
69
|
+
/** True when the shown snapshot is older than the TTL (last fetch failed). */
|
|
70
|
+
isStale(): boolean;
|
|
71
|
+
/** Current display mode; read every render so switching is immediate. */
|
|
72
|
+
getMode(): QuotaMode;
|
|
73
|
+
/** Notified whenever the snapshot, staleness, or mode changes so the footer can re-render. */
|
|
74
|
+
onQuotaChange(callback: () => void): () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createQuotaFooter(ctx: ExtensionContext, source: QuotaFooterSource): FooterFactory {
|
|
78
|
+
return (tui, theme, footerData) => {
|
|
79
|
+
const unsubscribeBranch = footerData.onBranchChange(() => tui.requestRender());
|
|
80
|
+
// Snapshots land asynchronously after the footer mounts; without this the
|
|
81
|
+
// quota would only appear the next time something else triggers a render.
|
|
82
|
+
const unsubscribeQuota = source.onQuotaChange(() => tui.requestRender());
|
|
83
|
+
const style: QuotaStyle = (color, text) => theme.fg(color, text);
|
|
84
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
85
|
+
let cache: StatsCache | undefined;
|
|
86
|
+
|
|
87
|
+
const sessionStats = () => {
|
|
88
|
+
const manager = ctx.sessionManager;
|
|
89
|
+
const entries = manager.getEntries();
|
|
90
|
+
const leafId = manager.getLeafId();
|
|
91
|
+
const model = ctx.model;
|
|
92
|
+
if (!cache || cache.entryCount !== entries.length || cache.leafId !== leafId || cache.model !== model) {
|
|
93
|
+
// Entries are append-only and every append moves the leaf, so these
|
|
94
|
+
// keys bound every input of both scans — the totals and the context
|
|
95
|
+
// usage projection. The footer renders on every frame, so neither may
|
|
96
|
+
// run per frame; the built-in footer caches them the same way.
|
|
97
|
+
cache = {
|
|
98
|
+
entryCount: entries.length,
|
|
99
|
+
leafId,
|
|
100
|
+
model,
|
|
101
|
+
contextUsage: ctx.getContextUsage(),
|
|
102
|
+
stats: computeSessionStats(entries),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return cache;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
dispose: () => {
|
|
110
|
+
unsubscribeBranch();
|
|
111
|
+
unsubscribeQuota();
|
|
112
|
+
},
|
|
113
|
+
invalidate() {},
|
|
114
|
+
render(width: number): string[] {
|
|
115
|
+
const model = ctx.model;
|
|
116
|
+
const { stats, contextUsage } = sessionStats();
|
|
117
|
+
const { totals, latestCacheHitRate } = stats;
|
|
118
|
+
|
|
119
|
+
// Line 1: cwd (+ branch, session name), dim like the built-in footer.
|
|
120
|
+
let pwd = formatCwd(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
121
|
+
const branch = footerData.getGitBranch();
|
|
122
|
+
if (branch) pwd += ` (${branch})`;
|
|
123
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
124
|
+
if (sessionName) pwd += ` • ${sessionName}`;
|
|
125
|
+
|
|
126
|
+
// Stats segments, styled per part so color resets cannot clear the dim baseline.
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
if (totals.input) parts.push(dim(`↑${formatTokens(totals.input)}`));
|
|
129
|
+
if (totals.output) parts.push(dim(`↓${formatTokens(totals.output)}`));
|
|
130
|
+
if (totals.cacheRead) parts.push(dim(`R${formatTokens(totals.cacheRead)}`));
|
|
131
|
+
if (totals.cacheWrite) parts.push(dim(`W${formatTokens(totals.cacheWrite)}`));
|
|
132
|
+
if ((totals.cacheRead > 0 || totals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
|
|
133
|
+
parts.push(dim(`CH${latestCacheHitRate.toFixed(1)}%`));
|
|
134
|
+
}
|
|
135
|
+
// Kimi Coding is subscription-backed despite API-key auth; same marker as the built-in footer.
|
|
136
|
+
const usingSubscription = model?.provider === "kimi-coding";
|
|
137
|
+
if (totals.cost || usingSubscription) {
|
|
138
|
+
parts.push(dim(`$${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Context segment, colored by pressure like the built-in footer.
|
|
142
|
+
// Built-in parity: an unknown percent (post-compaction) reads "?/200k"
|
|
143
|
+
// with no percent sign, while absent usage data reads "0.0%/0k".
|
|
144
|
+
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
145
|
+
const percent = contextUsage?.percent ?? null;
|
|
146
|
+
const percentText = percent !== null ? `${percent.toFixed(1)}%` : contextUsage ? "?" : "0.0%";
|
|
147
|
+
const contextDisplay = `${percentText}/${formatTokens(contextWindow)}`;
|
|
148
|
+
const contextPart =
|
|
149
|
+
percent !== null && percent > 90
|
|
150
|
+
? theme.fg("error", contextDisplay)
|
|
151
|
+
: percent !== null && percent > 70
|
|
152
|
+
? theme.fg("warning", contextDisplay)
|
|
153
|
+
: dim(contextDisplay);
|
|
154
|
+
|
|
155
|
+
// Right side: model name and thinking level, optionally behind a provider prefix.
|
|
156
|
+
let modelInfo = model?.id ?? "no-model";
|
|
157
|
+
if (model?.reasoning) {
|
|
158
|
+
const level = ctx.thinkingLevel ?? "off";
|
|
159
|
+
modelInfo = level === "off" ? `${modelInfo} • thinking off` : `${modelInfo} • ${level}`;
|
|
160
|
+
}
|
|
161
|
+
const showsPrefix = model !== undefined && footerData.getAvailableProviderCount() > 1;
|
|
162
|
+
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const snapshot = source.getSnapshot();
|
|
165
|
+
const stale = source.isStale();
|
|
166
|
+
const lines: string[] = [truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."))];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fit the stats line like the built-in footer: the first candidate that
|
|
170
|
+
* fits with at least two spaces of padding wins; on tight widths the
|
|
171
|
+
* bare model info is truncated from the right instead of dropped; and
|
|
172
|
+
* when even the stats alone exceed the width they are truncated with an
|
|
173
|
+
* ellipsis (built-in parity) rather than overflowing the terminal.
|
|
174
|
+
*/
|
|
175
|
+
const fitStatsLine = (candidates: readonly string[]): string => {
|
|
176
|
+
let left = [...parts, contextPart].filter(Boolean).join(" ");
|
|
177
|
+
let leftWidth = visibleWidth(left);
|
|
178
|
+
// Built-in parity: stats wider than the terminal are truncated with an
|
|
179
|
+
// ellipsis instead of overflowing the line.
|
|
180
|
+
if (leftWidth > width) {
|
|
181
|
+
left = truncateToWidth(left, width, "...");
|
|
182
|
+
leftWidth = visibleWidth(left);
|
|
183
|
+
}
|
|
184
|
+
for (const candidate of candidates) {
|
|
185
|
+
const rightWidth = visibleWidth(candidate);
|
|
186
|
+
if (leftWidth + 2 + rightWidth <= width) {
|
|
187
|
+
return left + " ".repeat(width - leftWidth - rightWidth) + candidate;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const availableForRight = width - leftWidth - 2;
|
|
191
|
+
if (availableForRight > 0) {
|
|
192
|
+
const truncated = truncateToWidth(candidates[candidates.length - 1], availableForRight, "");
|
|
193
|
+
return left + " ".repeat(Math.max(0, width - leftWidth - visibleWidth(truncated))) + truncated;
|
|
194
|
+
}
|
|
195
|
+
return left;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (source.getMode() === "line") {
|
|
199
|
+
// Line mode: the stats line keeps the built-in shape; the quota line is
|
|
200
|
+
// always present while a snapshot exists and renders every window in
|
|
201
|
+
// full detail — label, meter, percentage, countdown.
|
|
202
|
+
const candidates: string[] = [];
|
|
203
|
+
if (model && showsPrefix) candidates.push(dim(`(${model.provider}) ${modelInfo}`));
|
|
204
|
+
candidates.push(dim(modelInfo));
|
|
205
|
+
lines.push(fitStatsLine(candidates));
|
|
206
|
+
|
|
207
|
+
const meterWidth = METER_WIDTHS.find((entry) => width >= entry.minWidth)?.cells ?? 0;
|
|
208
|
+
const quotaLine = buildQuotaLine(snapshot, now, style, meterWidth, { stale });
|
|
209
|
+
if (quotaLine) lines.push(truncateToWidth(quotaLine, width, dim("...")));
|
|
210
|
+
} else {
|
|
211
|
+
// Inline mode: the prefix carries the quota behind the real provider id
|
|
212
|
+
// (never a renamed one). Styled in segments so the colored quota does
|
|
213
|
+
// not break the dim baseline. Countdowns appear only on alerting
|
|
214
|
+
// windows; a granted balance degrades from "¥18.67+¥1.00" to the
|
|
215
|
+
// exact total "¥19.67" when tight, then the quota drops to the
|
|
216
|
+
// built-in prefix.
|
|
217
|
+
const candidates: string[] = [];
|
|
218
|
+
if (model) {
|
|
219
|
+
const hasBalanceBreakdown = (snapshot?.items ?? []).some((item) => item.type === "balance" && item.granted >= 0.01);
|
|
220
|
+
for (const balanceDetail of hasBalanceBreakdown ? [true, false] : [false]) {
|
|
221
|
+
const inline = buildQuotaInline(snapshot, now, style, { stale, balanceDetail });
|
|
222
|
+
if (inline) candidates.push(dim(`(${model.provider} `) + inline + dim(`) ${modelInfo}`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (model && showsPrefix) candidates.push(dim(`(${model.provider}) ${modelInfo}`));
|
|
226
|
+
// Bare model info is always the last resort, as in the built-in footer.
|
|
227
|
+
candidates.push(dim(modelInfo));
|
|
228
|
+
lines.push(fitStatsLine(candidates));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Extension statuses keep their own line, as in the built-in footer.
|
|
232
|
+
const statuses = footerData.getExtensionStatuses();
|
|
233
|
+
if (statuses.size > 0) {
|
|
234
|
+
const statusLine = [...statuses.entries()]
|
|
235
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
236
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
237
|
+
.join(" ");
|
|
238
|
+
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
239
|
+
}
|
|
240
|
+
return lines;
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
}
|