@0xkahi/pi-qol 0.0.2 → 0.0.3
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/README.md +53 -2
- package/assets/config.schema.json +130 -1
- package/package.json +1 -1
- package/src/config-loader.ts +4 -0
- package/src/constants.ts +3 -0
- package/src/extensions/custom-footer/constants.ts +4 -0
- package/src/extensions/custom-footer/footer-component.ts +179 -0
- package/src/extensions/custom-footer/index.ts +18 -0
- package/src/extensions/custom-footer/progress-bar.ts +16 -0
- package/src/extensions/custom-footer/subscription-usage-manager.ts +91 -0
- package/src/extensions/custom-footer/token-stats.ts +144 -0
- package/src/extensions/custom-footer/types.ts +40 -0
- package/src/index.ts +2 -0
- package/src/libs/subscription-usage/strategy/anthropic-oauth-usage.strategy.ts +41 -0
- package/src/libs/subscription-usage/strategy/openai-codex-usage.strategy.ts +75 -0
- package/src/libs/subscription-usage/subscription-usage-api.util.ts +78 -0
- package/src/schemas/config.schema.ts +3 -0
- package/src/schemas/custom-footer-config.schema.ts +64 -0
- package/src/schemas/shared-config.schema.ts +3 -0
- package/src/utils/crayon.util.ts +58 -0
- package/src/utils/model-resolver.util.ts +2 -0
- package/src/utils/path.util.ts +4 -0
- package/src/utils/raw-data-parser.util.ts +14 -0
package/README.md
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
> Quality-of-life extensions for [pi](https://github.com/earendil-works/pi).
|
|
4
4
|
|
|
5
5
|
A small collection of opt-in extensions that smooth out everyday pi usage. Today
|
|
6
|
-
it ships **`auto_session_name`** — automatic, opencode-style session titling
|
|
7
|
-
**`model_select`** — an interactive model picker
|
|
6
|
+
it ships **`auto_session_name`** — automatic, opencode-style session titling,
|
|
7
|
+
**`model_select`** — an interactive model picker, and **`custom_footer`** — a
|
|
8
|
+
compact configurable interactive footer.
|
|
8
9
|
|
|
9
10
|
## Installation
|
|
10
11
|
|
|
@@ -44,6 +45,26 @@ provider filtering, and inline or overlay layouts,
|
|
|
44
45
|
|
|
45
46
|
allow keybindings with [pi-vim-keys](https://github.com/0xKahi/pi-vim-keys) with eventId of `pi.vimKeys.event:pi-qol.model_select`
|
|
46
47
|
|
|
48
|
+
### `custom_footer`
|
|
49
|
+
|
|
50
|
+
Replaces pi's built-in interactive footer with the same three-line layout, but
|
|
51
|
+
shows the current directory as an icon plus basename, supports hex color overrides,
|
|
52
|
+
can hide token/cache clusters, and adds an OAuth subscription-usage progress bar
|
|
53
|
+
for supported providers (Anthropic and OpenAI Codex).
|
|
54
|
+
|
|
55
|
+
- **Directory line** — directory icon + basename, with optional git branch and
|
|
56
|
+
session name.
|
|
57
|
+
- **Stats line** — cumulative input/output tokens, cache read/write/hit cluster,
|
|
58
|
+
session cost (or `(sub)` when on a subscription), context-window usage, and the
|
|
59
|
+
right-aligned model name (with provider prefix when multiple providers are
|
|
60
|
+
available).
|
|
61
|
+
- **Subscription usage** — a colored progress bar plus percentage and reset time
|
|
62
|
+
for the active OAuth provider. Usage is fetched lazily and cached, refreshing
|
|
63
|
+
at most once every 60 seconds per provider; auth/network failures simply hide
|
|
64
|
+
the segment.
|
|
65
|
+
|
|
66
|
+
Known difference from pi's built-in footer: the `(auto)` compaction suffix is
|
|
67
|
+
omitted because extensions do not expose reliable live state for it.
|
|
47
68
|
|
|
48
69
|
## Configuration
|
|
49
70
|
|
|
@@ -76,6 +97,17 @@ them (project overrides global):
|
|
|
76
97
|
"layout": "overlay",
|
|
77
98
|
"provider_filter": ["anthropic", "openai"],
|
|
78
99
|
"favourite": [{ "provider": "anthropic", "modelId": "claude-sonnet-4-5" }]
|
|
100
|
+
},
|
|
101
|
+
"custom_footer": {
|
|
102
|
+
"enabled": true,
|
|
103
|
+
"colors": {
|
|
104
|
+
"directory": "#A78BFA",
|
|
105
|
+
"modelName": "#60A5FA"
|
|
106
|
+
},
|
|
107
|
+
"display": {
|
|
108
|
+
"tokens": true,
|
|
109
|
+
"cache": true
|
|
110
|
+
}
|
|
79
111
|
}
|
|
80
112
|
}
|
|
81
113
|
```
|
|
@@ -111,6 +143,23 @@ falls back to the active session model and surfaces a warning notification.
|
|
|
111
143
|
| `provider_filter` | `string[]`| `[]` | Restrict searchable models to these providers. |
|
|
112
144
|
| `favourite` | `object[]`| `[]` | Favourite models shown in a separate tab (`provider`, `modelId`). |
|
|
113
145
|
|
|
146
|
+
#### `custom_footer`
|
|
147
|
+
|
|
148
|
+
| Key | Type | Default | Description |
|
|
149
|
+
| --------------------------- | --------- | --------- | ------------------------------------------------ |
|
|
150
|
+
| `enabled` | `boolean` | `false` | Replace pi's built-in interactive footer. |
|
|
151
|
+
| `display.tokens` | `boolean` | `true` | Show cumulative input/output token counts. |
|
|
152
|
+
| `display.cache` | `boolean` | `true` | Show the custom cache read/write/hit cluster. |
|
|
153
|
+
| `colors.directory` | `hex` | — | Color the directory icon and basename. |
|
|
154
|
+
| `colors.modelName` | `hex` | — | Color the right-aligned model name. |
|
|
155
|
+
| `colors.anthropicUsage` | `hex` | `#D97706` | Color the Claude subscription usage segment (bar + percentage). |
|
|
156
|
+
| `colors.codexUsage` | `hex` | `#10B981` | Color the Codex subscription usage segment (bar + percentage). |
|
|
157
|
+
| `icons.directory` | `string` | nerd font | Glyph shown before the directory basename. |
|
|
158
|
+
| `icons.cache` | `string` | nerd font | Glyph for the cache cluster. |
|
|
159
|
+
| `icons.cacheRead` | `string` | nerd font | Glyph for cache-read tokens. |
|
|
160
|
+
| `icons.cacheWrite` | `string` | nerd font | Glyph for cache-write tokens. |
|
|
161
|
+
| `icons.refresh` | `string` | nerd font | Glyph shown before the subscription reset time. |
|
|
162
|
+
|
|
114
163
|
Project config shallow-merges each top-level section over global config. If project
|
|
115
164
|
config sets `model_select.favourite`, it replaces the global favourites array.
|
|
116
165
|
|
|
@@ -152,6 +201,8 @@ src/
|
|
|
152
201
|
utils/ # path + model-resolution helpers
|
|
153
202
|
extensions/
|
|
154
203
|
auto-session-name/ # the auto_session_name feature
|
|
204
|
+
model-select/ # the model_select feature
|
|
205
|
+
custom-footer/ # the custom_footer feature
|
|
155
206
|
scripts/ # JSON schema generation
|
|
156
207
|
assets/config.schema.json # generated config schema
|
|
157
208
|
```
|
|
@@ -109,11 +109,140 @@
|
|
|
109
109
|
"layout"
|
|
110
110
|
],
|
|
111
111
|
"additionalProperties": false
|
|
112
|
+
},
|
|
113
|
+
"custom_footer": {
|
|
114
|
+
"default": {
|
|
115
|
+
"enabled": false,
|
|
116
|
+
"colors": {
|
|
117
|
+
"anthropicUsage": "#D97706",
|
|
118
|
+
"codexUsage": "#10B981"
|
|
119
|
+
},
|
|
120
|
+
"icons": {
|
|
121
|
+
"directory": " ",
|
|
122
|
+
"refresh": "",
|
|
123
|
+
"cache": " ",
|
|
124
|
+
"cacheRead": " ",
|
|
125
|
+
"cacheWrite": " "
|
|
126
|
+
},
|
|
127
|
+
"display": {
|
|
128
|
+
"tokens": true,
|
|
129
|
+
"cache": true
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"type": "object",
|
|
133
|
+
"properties": {
|
|
134
|
+
"enabled": {
|
|
135
|
+
"default": false,
|
|
136
|
+
"type": "boolean"
|
|
137
|
+
},
|
|
138
|
+
"colors": {
|
|
139
|
+
"default": {
|
|
140
|
+
"anthropicUsage": "#D97706",
|
|
141
|
+
"codexUsage": "#10B981"
|
|
142
|
+
},
|
|
143
|
+
"type": "object",
|
|
144
|
+
"properties": {
|
|
145
|
+
"directory": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"pattern": "^#[0-9a-fA-F]{6}$"
|
|
148
|
+
},
|
|
149
|
+
"modelName": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"pattern": "^#[0-9a-fA-F]{6}$"
|
|
152
|
+
},
|
|
153
|
+
"anthropicUsage": {
|
|
154
|
+
"default": "#D97706",
|
|
155
|
+
"type": "string",
|
|
156
|
+
"pattern": "^#[0-9a-fA-F]{6}$"
|
|
157
|
+
},
|
|
158
|
+
"codexUsage": {
|
|
159
|
+
"default": "#10B981",
|
|
160
|
+
"type": "string",
|
|
161
|
+
"pattern": "^#[0-9a-fA-F]{6}$"
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"required": [
|
|
165
|
+
"anthropicUsage",
|
|
166
|
+
"codexUsage"
|
|
167
|
+
],
|
|
168
|
+
"additionalProperties": false
|
|
169
|
+
},
|
|
170
|
+
"icons": {
|
|
171
|
+
"default": {
|
|
172
|
+
"directory": " ",
|
|
173
|
+
"refresh": "",
|
|
174
|
+
"cache": " ",
|
|
175
|
+
"cacheRead": " ",
|
|
176
|
+
"cacheWrite": " "
|
|
177
|
+
},
|
|
178
|
+
"type": "object",
|
|
179
|
+
"properties": {
|
|
180
|
+
"directory": {
|
|
181
|
+
"default": " ",
|
|
182
|
+
"type": "string"
|
|
183
|
+
},
|
|
184
|
+
"refresh": {
|
|
185
|
+
"default": "",
|
|
186
|
+
"type": "string"
|
|
187
|
+
},
|
|
188
|
+
"cache": {
|
|
189
|
+
"default": " ",
|
|
190
|
+
"type": "string"
|
|
191
|
+
},
|
|
192
|
+
"cacheRead": {
|
|
193
|
+
"default": " ",
|
|
194
|
+
"type": "string"
|
|
195
|
+
},
|
|
196
|
+
"cacheWrite": {
|
|
197
|
+
"default": " ",
|
|
198
|
+
"type": "string"
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
"required": [
|
|
202
|
+
"directory",
|
|
203
|
+
"refresh",
|
|
204
|
+
"cache",
|
|
205
|
+
"cacheRead",
|
|
206
|
+
"cacheWrite"
|
|
207
|
+
],
|
|
208
|
+
"additionalProperties": false
|
|
209
|
+
},
|
|
210
|
+
"display": {
|
|
211
|
+
"default": {
|
|
212
|
+
"tokens": true,
|
|
213
|
+
"cache": true
|
|
214
|
+
},
|
|
215
|
+
"type": "object",
|
|
216
|
+
"properties": {
|
|
217
|
+
"tokens": {
|
|
218
|
+
"default": true,
|
|
219
|
+
"type": "boolean"
|
|
220
|
+
},
|
|
221
|
+
"cache": {
|
|
222
|
+
"default": true,
|
|
223
|
+
"type": "boolean"
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
"required": [
|
|
227
|
+
"tokens",
|
|
228
|
+
"cache"
|
|
229
|
+
],
|
|
230
|
+
"additionalProperties": false
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
"required": [
|
|
234
|
+
"enabled",
|
|
235
|
+
"colors",
|
|
236
|
+
"icons",
|
|
237
|
+
"display"
|
|
238
|
+
],
|
|
239
|
+
"additionalProperties": false
|
|
112
240
|
}
|
|
113
241
|
},
|
|
114
242
|
"required": [
|
|
115
243
|
"auto_session_name",
|
|
116
|
-
"model_select"
|
|
244
|
+
"model_select",
|
|
245
|
+
"custom_footer"
|
|
117
246
|
],
|
|
118
247
|
"additionalProperties": false
|
|
119
248
|
}
|
package/package.json
CHANGED
package/src/config-loader.ts
CHANGED
|
@@ -24,6 +24,10 @@ export class ConfigLoader {
|
|
|
24
24
|
return this.config.model_select;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
getCustomFooter(): Config['custom_footer'] {
|
|
28
|
+
return this.config.custom_footer;
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
initializeConfig(ctx: ExtensionContext): { success: boolean; error?: string } {
|
|
28
32
|
let merged: Config = this.defaultConfig;
|
|
29
33
|
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,7 @@ export const EXTENSION_ID = 'pi-qol';
|
|
|
5
5
|
const SUB_EXTENSION_IDS = {
|
|
6
6
|
auto_session_name: 'auto_session_name',
|
|
7
7
|
model_select: 'model_select',
|
|
8
|
+
custom_footer: 'custom_footer',
|
|
8
9
|
} as const;
|
|
9
10
|
|
|
10
11
|
export type SubExtentionIds = ObjectValues<typeof SUB_EXTENSION_IDS>;
|
|
@@ -16,3 +17,5 @@ export const piVimKeyEventId = (type: SubExtentionIds, extra: string[] = []) =>
|
|
|
16
17
|
});
|
|
17
18
|
return id;
|
|
18
19
|
};
|
|
20
|
+
|
|
21
|
+
export const COLOR_HEX_REGEX = /^#[0-9a-fA-F]{6}$/;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
3
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
4
|
+
import { crayon } from '../../utils/crayon.util';
|
|
5
|
+
import { pickSubscriptionUsageWindow, resolveSupportedProvider, SubscriptionUsageManager } from './subscription-usage-manager';
|
|
6
|
+
import { buildStatsLeft, buildSubscriptionUsageSegment, calculateUsageTotals } from './token-stats';
|
|
7
|
+
import type { CustomFooterComponentDeps, CustomFooterConfig } from './types';
|
|
8
|
+
|
|
9
|
+
const MIN_STATS_MODEL_PADDING = 2;
|
|
10
|
+
|
|
11
|
+
function sanitizeStatusText(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/[\r\n\t]/g, ' ')
|
|
14
|
+
.replace(/ +/g, ' ')
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class CustomFooterComponent implements Component {
|
|
19
|
+
private readonly usageManager: SubscriptionUsageManager;
|
|
20
|
+
private readonly unsubscribeBranch: () => void;
|
|
21
|
+
private restoredDefaultFooter = false;
|
|
22
|
+
private disposed = false;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly deps: CustomFooterComponentDeps) {
|
|
25
|
+
this.usageManager = new SubscriptionUsageManager(undefined, () => {
|
|
26
|
+
if (!this.disposed) deps.tui.requestRender();
|
|
27
|
+
});
|
|
28
|
+
this.unsubscribeBranch = deps.footerData.onBranchChange(() => deps.tui.requestRender());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
invalidate(): void {
|
|
32
|
+
// All footer data is read live on each render.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dispose(): void {
|
|
36
|
+
this.disposed = true;
|
|
37
|
+
this.unsubscribeBranch();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
render(width: number): string[] {
|
|
41
|
+
if (width <= 0) return [];
|
|
42
|
+
|
|
43
|
+
if (!this.deps.config.isEnabled('custom_footer')) {
|
|
44
|
+
if (!this.restoredDefaultFooter) {
|
|
45
|
+
this.restoredDefaultFooter = true;
|
|
46
|
+
this.deps.ctx.ui.setFooter(undefined);
|
|
47
|
+
this.deps.tui.requestRender();
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.restoredDefaultFooter = false;
|
|
53
|
+
|
|
54
|
+
const config = this.deps.config.getCustomFooter();
|
|
55
|
+
const lines = [this.renderPathLine(width, config), this.renderStatsLine(width, config)];
|
|
56
|
+
|
|
57
|
+
const extensionStatuses = this.deps.footerData.getExtensionStatuses();
|
|
58
|
+
if (extensionStatuses.size > 0) {
|
|
59
|
+
const statusLine = Array.from(extensionStatuses.entries())
|
|
60
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
61
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
62
|
+
.join(' ');
|
|
63
|
+
lines.push(truncateToWidth(statusLine, width, this.deps.theme.fg('dim', '...')));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private renderPathLine(width: number, config: CustomFooterConfig): string {
|
|
70
|
+
const cwd = this.deps.ctx.sessionManager.getCwd();
|
|
71
|
+
const directoryText = `${config.icons.directory}${basename(cwd) || cwd}`;
|
|
72
|
+
const branch = this.deps.footerData.getGitBranch();
|
|
73
|
+
const sessionName = this.deps.ctx.sessionManager.getSessionName();
|
|
74
|
+
|
|
75
|
+
let line: string;
|
|
76
|
+
if (config.colors.directory) {
|
|
77
|
+
line = crayon.colorize(directoryText, { fg: config.colors.directory });
|
|
78
|
+
if (branch) line += this.deps.theme.fg('dim', ` (${branch})`);
|
|
79
|
+
if (sessionName) line += this.deps.theme.fg('dim', ` • ${sessionName}`);
|
|
80
|
+
} else {
|
|
81
|
+
let rawLine = directoryText;
|
|
82
|
+
if (branch) rawLine += ` (${branch})`;
|
|
83
|
+
if (sessionName) rawLine += ` • ${sessionName}`;
|
|
84
|
+
line = this.deps.theme.fg('dim', rawLine);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return truncateToWidth(line, width, this.deps.theme.fg('dim', '...'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private renderStatsLine(width: number, config: CustomFooterConfig): string {
|
|
91
|
+
const model = this.deps.ctx.model;
|
|
92
|
+
const usingSubscription = model ? this.deps.ctx.modelRegistry.isUsingOAuth(model) : false;
|
|
93
|
+
const contextUsage = this.deps.ctx.getContextUsage();
|
|
94
|
+
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
95
|
+
const subscriptionUsageSegment = this.renderSubscriptionUsageSegment(config, usingSubscription);
|
|
96
|
+
|
|
97
|
+
let statsLeft = buildStatsLeft({
|
|
98
|
+
totals: calculateUsageTotals(this.deps.ctx.sessionManager.getEntries()),
|
|
99
|
+
display: config.display,
|
|
100
|
+
icons: config.icons,
|
|
101
|
+
contextUsage,
|
|
102
|
+
contextWindow,
|
|
103
|
+
usingSubscription,
|
|
104
|
+
subscriptionUsageSegment,
|
|
105
|
+
theme: this.deps.theme,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
109
|
+
if (statsLeftWidth > width) {
|
|
110
|
+
statsLeft = truncateToWidth(statsLeft, width, this.deps.theme.fg('dim', '...'));
|
|
111
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rightWithoutProvider = this.buildModelNameSegment(config);
|
|
115
|
+
let rightSide = rightWithoutProvider;
|
|
116
|
+
|
|
117
|
+
if (this.deps.footerData.getAvailableProviderCount() > 1 && model) {
|
|
118
|
+
const candidate = `${this.deps.theme.fg('dim', `(${model.provider}) `)}${rightWithoutProvider}`;
|
|
119
|
+
if (statsLeftWidth + MIN_STATS_MODEL_PADDING + visibleWidth(candidate) <= width) {
|
|
120
|
+
rightSide = candidate;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
125
|
+
const totalNeeded = statsLeftWidth + MIN_STATS_MODEL_PADDING + rightSideWidth;
|
|
126
|
+
|
|
127
|
+
if (totalNeeded <= width) {
|
|
128
|
+
return `${statsLeft}${' '.repeat(width - statsLeftWidth - rightSideWidth)}${rightSide}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const availableForRight = width - statsLeftWidth - MIN_STATS_MODEL_PADDING;
|
|
132
|
+
if (availableForRight <= 0) return statsLeft;
|
|
133
|
+
|
|
134
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, '');
|
|
135
|
+
const truncatedRightWidth = visibleWidth(truncatedRight);
|
|
136
|
+
return `${statsLeft}${' '.repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth))}${truncatedRight}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private buildModelNameSegment(config: CustomFooterConfig): string {
|
|
140
|
+
const model = this.deps.ctx.model;
|
|
141
|
+
const modelName = model?.id ?? 'no-model';
|
|
142
|
+
const modelNameSegment = config.colors.modelName
|
|
143
|
+
? crayon.colorize(modelName, { fg: config.colors.modelName })
|
|
144
|
+
: this.deps.theme.fg('dim', modelName);
|
|
145
|
+
|
|
146
|
+
if (!model?.reasoning) return modelNameSegment;
|
|
147
|
+
|
|
148
|
+
const thinkingLevel = this.deps.getThinkingLevel() || 'off';
|
|
149
|
+
const thinkingLabel = thinkingLevel === 'off' ? 'thinking off' : thinkingLevel;
|
|
150
|
+
return `${modelNameSegment}${this.deps.theme.fg('dim', ` • ${thinkingLabel}`)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private renderSubscriptionUsageSegment(config: CustomFooterConfig, usingSubscription: boolean): string | undefined {
|
|
154
|
+
const model = this.deps.ctx.model;
|
|
155
|
+
if (!model || !usingSubscription) return undefined;
|
|
156
|
+
|
|
157
|
+
const provider = resolveSupportedProvider(model.provider);
|
|
158
|
+
if (!provider) return undefined;
|
|
159
|
+
|
|
160
|
+
const response = this.usageManager.ensureFresh(provider);
|
|
161
|
+
if (!response) return undefined;
|
|
162
|
+
|
|
163
|
+
const window = pickSubscriptionUsageWindow(response.rateWindow);
|
|
164
|
+
if (!window) return undefined;
|
|
165
|
+
|
|
166
|
+
return buildSubscriptionUsageSegment({
|
|
167
|
+
colors: config.colors,
|
|
168
|
+
icons: config.icons,
|
|
169
|
+
theme: this.deps.theme,
|
|
170
|
+
usage: {
|
|
171
|
+
provider,
|
|
172
|
+
responseLabel: response.label,
|
|
173
|
+
windowLabel: window.label,
|
|
174
|
+
usedPercent: window.usedPercent,
|
|
175
|
+
resetDescription: window.resetAt ? this.usageManager.formatResetDescription(window.resetAt) : undefined,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { ConfigLoader } from '../../config-loader';
|
|
3
|
+
import { CustomFooterComponent } from './footer-component';
|
|
4
|
+
|
|
5
|
+
export function registerCustomFooter(pi: ExtensionAPI, deps: { config: ConfigLoader }): void {
|
|
6
|
+
let installed = false;
|
|
7
|
+
|
|
8
|
+
pi.on('session_start', (_event, ctx) => {
|
|
9
|
+
if (!deps.config.isEnabled('custom_footer')) return;
|
|
10
|
+
if (installed) return;
|
|
11
|
+
|
|
12
|
+
ctx.ui.setFooter(
|
|
13
|
+
(tui, theme, footerData) =>
|
|
14
|
+
new CustomFooterComponent({ tui, theme, footerData, ctx, config: deps.config, getThinkingLevel: () => pi.getThinkingLevel() }),
|
|
15
|
+
);
|
|
16
|
+
installed = true;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { EMPTY_BAR_ICON, FILLED_BAR_ICON, SUBSCRIPTION_BAR_WIDTH } from './constants';
|
|
2
|
+
|
|
3
|
+
export function clampPercent(usedPercent: number): number {
|
|
4
|
+
return Number.isFinite(usedPercent) ? Math.max(0, Math.min(100, usedPercent)) : 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function renderProgressBar(usedPercent: number, width = SUBSCRIPTION_BAR_WIDTH): { filled: string; empty: string } {
|
|
8
|
+
const barWidth = Math.max(0, Math.floor(width));
|
|
9
|
+
const pct = clampPercent(usedPercent);
|
|
10
|
+
const filledCount = Math.round((pct / 100) * barWidth);
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
filled: FILLED_BAR_ICON.repeat(filledCount),
|
|
14
|
+
empty: EMPTY_BAR_ICON.repeat(barWidth - filledCount),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AnthropicOauthUsageStrategy } from '../../libs/subscription-usage/strategy/anthropic-oauth-usage.strategy';
|
|
2
|
+
import { OpenAiCodexUsageStrategy } from '../../libs/subscription-usage/strategy/openai-codex-usage.strategy';
|
|
3
|
+
import {
|
|
4
|
+
type FetchUsageResponse,
|
|
5
|
+
type RateWindow,
|
|
6
|
+
SubscriptionUsageApi,
|
|
7
|
+
type SubscriptionUsageStrategy,
|
|
8
|
+
} from '../../libs/subscription-usage/subscription-usage-api.util';
|
|
9
|
+
import { SUBSCRIPTION_USAGE_TTL_MS } from './constants';
|
|
10
|
+
import type { SupportedProvider } from './types';
|
|
11
|
+
|
|
12
|
+
export type SubscriptionUsageApiLike = Pick<SubscriptionUsageApi, 'fetchUsage' | 'formatResetDescription'>;
|
|
13
|
+
|
|
14
|
+
export function resolveSupportedProvider(provider: string | undefined): SupportedProvider | undefined {
|
|
15
|
+
switch (provider?.toLowerCase()) {
|
|
16
|
+
case 'anthropic':
|
|
17
|
+
return 'anthropic';
|
|
18
|
+
case 'openai-codex':
|
|
19
|
+
case 'codex':
|
|
20
|
+
case 'openai':
|
|
21
|
+
case 'chatgpt':
|
|
22
|
+
return 'openai-codex';
|
|
23
|
+
default:
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pickSubscriptionUsageWindow(windows: readonly RateWindow[]): RateWindow | undefined {
|
|
29
|
+
let earliestWithReset: RateWindow | undefined;
|
|
30
|
+
|
|
31
|
+
for (const window of windows) {
|
|
32
|
+
if (!window.resetAt) continue;
|
|
33
|
+
if (!earliestWithReset?.resetAt || window.resetAt.getTime() <= earliestWithReset.resetAt.getTime()) {
|
|
34
|
+
earliestWithReset = window;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return earliestWithReset ?? windows[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class SubscriptionUsageManager {
|
|
42
|
+
private cache = new Map<SupportedProvider, FetchUsageResponse>();
|
|
43
|
+
private inFlight = new Set<SupportedProvider>();
|
|
44
|
+
private lastFetchAt = new Map<SupportedProvider, number>();
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private api: SubscriptionUsageApiLike = new SubscriptionUsageApi(),
|
|
48
|
+
private onUpdate?: () => void,
|
|
49
|
+
private ttlMs = SUBSCRIPTION_USAGE_TTL_MS,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
get(provider: SupportedProvider): FetchUsageResponse | undefined {
|
|
53
|
+
return this.cache.get(provider);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ensureFresh(provider: SupportedProvider): FetchUsageResponse | undefined {
|
|
57
|
+
const last = this.lastFetchAt.get(provider) ?? 0;
|
|
58
|
+
if (!this.inFlight.has(provider) && Date.now() - last > this.ttlMs) {
|
|
59
|
+
this.refresh(provider);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return this.cache.get(provider);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
formatResetDescription(date: Date): string {
|
|
66
|
+
return this.api.formatResetDescription(date);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private refresh(provider: SupportedProvider): void {
|
|
70
|
+
this.inFlight.add(provider);
|
|
71
|
+
this.lastFetchAt.set(provider, Date.now());
|
|
72
|
+
|
|
73
|
+
void this.api
|
|
74
|
+
.fetchUsage(this.strategyFor(provider))
|
|
75
|
+
.then(response => {
|
|
76
|
+
if (!response) return;
|
|
77
|
+
this.cache.set(provider, response);
|
|
78
|
+
this.onUpdate?.();
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {
|
|
81
|
+
// Best-effort status: auth/network failures simply hide the segment.
|
|
82
|
+
})
|
|
83
|
+
.finally(() => {
|
|
84
|
+
this.inFlight.delete(provider);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private strategyFor(provider: SupportedProvider): SubscriptionUsageStrategy {
|
|
89
|
+
return provider === 'anthropic' ? new AnthropicOauthUsageStrategy() : new OpenAiCodexUsageStrategy();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ContextUsage, SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { crayon } from '../../utils/crayon.util';
|
|
3
|
+
import { clampPercent, renderProgressBar } from './progress-bar';
|
|
4
|
+
import type { CustomFooterColors, CustomFooterDisplay, CustomFooterIcons, FooterTheme, SupportedProvider, UsageTotals } from './types';
|
|
5
|
+
|
|
6
|
+
export type SubscriptionUsageSegmentInput = {
|
|
7
|
+
provider: SupportedProvider;
|
|
8
|
+
responseLabel: string;
|
|
9
|
+
windowLabel: string;
|
|
10
|
+
usedPercent: number;
|
|
11
|
+
resetDescription?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BuildStatsLeftOptions = {
|
|
15
|
+
totals: UsageTotals;
|
|
16
|
+
display: CustomFooterDisplay;
|
|
17
|
+
icons: Pick<CustomFooterIcons, 'cache' | 'cacheRead' | 'cacheWrite'>;
|
|
18
|
+
contextUsage: ContextUsage | undefined;
|
|
19
|
+
contextWindow: number;
|
|
20
|
+
usingSubscription: boolean;
|
|
21
|
+
subscriptionUsageSegment?: string;
|
|
22
|
+
theme: FooterTheme;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function formatTokens(count: number): string {
|
|
26
|
+
if (count < 1000) return count.toString();
|
|
27
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
28
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
29
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
30
|
+
return `${Math.round(count / 1000000)}M`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function calculateUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
|
|
34
|
+
const totals: UsageTotals = {
|
|
35
|
+
totalInput: 0,
|
|
36
|
+
totalOutput: 0,
|
|
37
|
+
totalCacheRead: 0,
|
|
38
|
+
totalCacheWrite: 0,
|
|
39
|
+
totalCost: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
|
|
44
|
+
|
|
45
|
+
const usage = entry.message.usage;
|
|
46
|
+
totals.totalInput += usage.input;
|
|
47
|
+
totals.totalOutput += usage.output;
|
|
48
|
+
totals.totalCacheRead += usage.cacheRead;
|
|
49
|
+
totals.totalCacheWrite += usage.cacheWrite;
|
|
50
|
+
totals.totalCost += usage.cost.total;
|
|
51
|
+
|
|
52
|
+
const latestPromptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
53
|
+
totals.latestCacheHitRate = latestPromptTokens > 0 ? (usage.cacheRead / latestPromptTokens) * 100 : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return totals;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildSubscriptionUsageSegment({
|
|
60
|
+
colors,
|
|
61
|
+
icons,
|
|
62
|
+
theme,
|
|
63
|
+
usage,
|
|
64
|
+
}: {
|
|
65
|
+
colors: Pick<CustomFooterColors, 'anthropicUsage' | 'codexUsage'>;
|
|
66
|
+
icons: Pick<CustomFooterIcons, 'refresh'>;
|
|
67
|
+
theme: FooterTheme;
|
|
68
|
+
usage: SubscriptionUsageSegmentInput;
|
|
69
|
+
}): string {
|
|
70
|
+
const providerColor = usage.provider === 'anthropic' ? colors.anthropicUsage : colors.codexUsage;
|
|
71
|
+
const pct = clampPercent(usage.usedPercent);
|
|
72
|
+
const roundedPercent = Math.round(pct).toString();
|
|
73
|
+
const bar = renderProgressBar(pct);
|
|
74
|
+
|
|
75
|
+
const resetParts = usage.resetDescription ? [icons.refresh, usage.resetDescription].filter(Boolean).join(' ') : '';
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
crayon.colorize(usage.responseLabel, { fg: providerColor }),
|
|
79
|
+
crayon.colorize(usage.windowLabel, { fg: providerColor }),
|
|
80
|
+
`${crayon.colorize(bar.filled, { fg: providerColor })}${theme.fg('dim', bar.empty)}`,
|
|
81
|
+
crayon.colorize(`${roundedPercent}%`, { fg: providerColor }),
|
|
82
|
+
resetParts ? theme.fg('dim', resetParts) : undefined,
|
|
83
|
+
]
|
|
84
|
+
.filter((part): part is string => Boolean(part))
|
|
85
|
+
.join(' ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildStatsLeft({
|
|
89
|
+
contextUsage,
|
|
90
|
+
contextWindow,
|
|
91
|
+
display,
|
|
92
|
+
icons,
|
|
93
|
+
subscriptionUsageSegment,
|
|
94
|
+
theme,
|
|
95
|
+
totals,
|
|
96
|
+
usingSubscription,
|
|
97
|
+
}: BuildStatsLeftOptions): string {
|
|
98
|
+
const parts: string[] = [];
|
|
99
|
+
|
|
100
|
+
if (display.tokens) {
|
|
101
|
+
if (totals.totalInput) parts.push(theme.fg('dim', `↑${formatTokens(totals.totalInput)}`));
|
|
102
|
+
if (totals.totalOutput) parts.push(theme.fg('dim', `↓${formatTokens(totals.totalOutput)}`));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (display.cache && (totals.totalCacheRead > 0 || totals.totalCacheWrite > 0)) {
|
|
106
|
+
const hitRate = totals.latestCacheHitRate !== undefined ? ` ${totals.latestCacheHitRate.toFixed(1)}%` : '';
|
|
107
|
+
parts.push(
|
|
108
|
+
theme.fg(
|
|
109
|
+
'dim',
|
|
110
|
+
`${icons.cache}[${icons.cacheRead}${formatTokens(totals.totalCacheRead)} ${icons.cacheWrite}${formatTokens(totals.totalCacheWrite)}${hitRate}]`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (totals.totalCost || usingSubscription) {
|
|
116
|
+
parts.push(theme.fg('dim', `$${totals.totalCost.toFixed(3)}${usingSubscription ? ' (sub)' : ''}`));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
parts.push(buildContextUsageSegment({ contextUsage, contextWindow, theme }));
|
|
120
|
+
|
|
121
|
+
if (subscriptionUsageSegment) {
|
|
122
|
+
parts.push(theme.fg('dim', '•'), subscriptionUsageSegment);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return parts.join(' ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildContextUsageSegment({
|
|
129
|
+
contextUsage,
|
|
130
|
+
contextWindow,
|
|
131
|
+
theme,
|
|
132
|
+
}: {
|
|
133
|
+
contextUsage: ContextUsage | undefined;
|
|
134
|
+
contextWindow: number;
|
|
135
|
+
theme: FooterTheme;
|
|
136
|
+
}): string {
|
|
137
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
138
|
+
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : '?';
|
|
139
|
+
const contextPercentDisplay = contextPercent === '?' ? `?/${formatTokens(contextWindow)}` : `${contextPercent}%/${formatTokens(contextWindow)}`;
|
|
140
|
+
|
|
141
|
+
if (contextPercentValue > 90) return theme.fg('error', contextPercentDisplay);
|
|
142
|
+
if (contextPercentValue > 70) return theme.fg('warning', contextPercentDisplay);
|
|
143
|
+
return theme.fg('dim', contextPercentDisplay);
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { TUI } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { ConfigLoader } from '../../config-loader';
|
|
4
|
+
import type { Config } from '../../schemas/config.schema';
|
|
5
|
+
|
|
6
|
+
export type SupportedProvider = 'anthropic' | 'openai-codex';
|
|
7
|
+
|
|
8
|
+
export type CustomFooterConfig = Config['custom_footer'];
|
|
9
|
+
export type CustomFooterColors = CustomFooterConfig['colors'];
|
|
10
|
+
export type CustomFooterDisplay = CustomFooterConfig['display'];
|
|
11
|
+
export type CustomFooterIcons = CustomFooterConfig['icons'];
|
|
12
|
+
|
|
13
|
+
export type FooterTheme = {
|
|
14
|
+
fg(color: 'dim' | 'error' | 'warning', text: string): string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type FooterDataProvider = {
|
|
18
|
+
getGitBranch(): string | null;
|
|
19
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
20
|
+
getAvailableProviderCount(): number;
|
|
21
|
+
onBranchChange(cb: () => void): () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CustomFooterComponentDeps = {
|
|
25
|
+
tui: TUI;
|
|
26
|
+
theme: FooterTheme;
|
|
27
|
+
footerData: FooterDataProvider;
|
|
28
|
+
ctx: ExtensionContext;
|
|
29
|
+
config: ConfigLoader;
|
|
30
|
+
getThinkingLevel: () => string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type UsageTotals = {
|
|
34
|
+
totalInput: number;
|
|
35
|
+
totalOutput: number;
|
|
36
|
+
totalCacheRead: number;
|
|
37
|
+
totalCacheWrite: number;
|
|
38
|
+
totalCost: number;
|
|
39
|
+
latestCacheHitRate?: number;
|
|
40
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { ConfigLoader } from './config-loader';
|
|
3
3
|
import { registerAutoSessionName } from './extensions/auto-session-name';
|
|
4
|
+
import { registerCustomFooter } from './extensions/custom-footer';
|
|
4
5
|
import { registerModelSelect } from './extensions/model-select';
|
|
5
6
|
|
|
6
7
|
export default function (pi: ExtensionAPI) {
|
|
@@ -13,4 +14,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
13
14
|
|
|
14
15
|
registerAutoSessionName(pi, { config });
|
|
15
16
|
registerModelSelect(pi, { config });
|
|
17
|
+
registerCustomFooter(pi, { config });
|
|
16
18
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { RawDataParser } from '../../../utils/raw-data-parser.util';
|
|
2
|
+
import type { ProviderAuth, RateWindow, SubscriptionUsageStrategy } from '../subscription-usage-api.util';
|
|
3
|
+
|
|
4
|
+
export class AnthropicOauthUsageStrategy implements SubscriptionUsageStrategy {
|
|
5
|
+
readonly provider = 'anthropic';
|
|
6
|
+
readonly label = 'Claude';
|
|
7
|
+
|
|
8
|
+
async fetchUsage(auth: ProviderAuth) {
|
|
9
|
+
const response = await fetch(this.request(auth));
|
|
10
|
+
const data = RawDataParser.asRecord(await response.json());
|
|
11
|
+
if (!data) return;
|
|
12
|
+
|
|
13
|
+
const windows: RateWindow[] = [];
|
|
14
|
+
for (const [key, label] of [
|
|
15
|
+
['five_hour', '5h'],
|
|
16
|
+
['seven_day', 'Week'],
|
|
17
|
+
] as const) {
|
|
18
|
+
const source = RawDataParser.asRecord(data[key]);
|
|
19
|
+
const usedPercent = RawDataParser.numberValue(source?.utilization);
|
|
20
|
+
if (usedPercent === undefined) continue;
|
|
21
|
+
const resetAt = RawDataParser.stringValue(source?.resets_at);
|
|
22
|
+
const resetDate = resetAt ? new Date(resetAt) : undefined;
|
|
23
|
+
windows.push({
|
|
24
|
+
label,
|
|
25
|
+
usedPercent,
|
|
26
|
+
resetAt: resetDate && Number.isFinite(resetDate.getTime()) ? resetDate : undefined,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return windows;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private request(opts: ProviderAuth) {
|
|
33
|
+
return new Request('https://api.anthropic.com/api/oauth/usage', {
|
|
34
|
+
method: 'GET',
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${opts.token}`,
|
|
37
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { RawDataParser } from '../../../utils/raw-data-parser.util';
|
|
2
|
+
import type { ProviderAuth, RateWindow, SubscriptionUsageStrategy } from '../subscription-usage-api.util';
|
|
3
|
+
|
|
4
|
+
const PRIMARY_WINDOW_FALLBACK_SECONDS = 10800;
|
|
5
|
+
const SECONDARY_WINDOW_FALLBACK_SECONDS = 86400;
|
|
6
|
+
|
|
7
|
+
export class OpenAiCodexUsageStrategy implements SubscriptionUsageStrategy {
|
|
8
|
+
readonly provider = 'openai-codex';
|
|
9
|
+
readonly label = 'Codex';
|
|
10
|
+
|
|
11
|
+
async fetchUsage(auth: ProviderAuth) {
|
|
12
|
+
const response = await fetch(this.request(auth));
|
|
13
|
+
if (!response.ok) return;
|
|
14
|
+
|
|
15
|
+
const data = RawDataParser.asRecord(await response.json());
|
|
16
|
+
if (!data) return;
|
|
17
|
+
|
|
18
|
+
const windows: RateWindow[] = [];
|
|
19
|
+
this.pushRateLimitWindows(windows, RawDataParser.asRecord(data.rate_limit));
|
|
20
|
+
|
|
21
|
+
const additionalRateLimits = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : [];
|
|
22
|
+
for (const item of additionalRateLimits) {
|
|
23
|
+
const entry = RawDataParser.asRecord(item);
|
|
24
|
+
if (!entry) continue;
|
|
25
|
+
|
|
26
|
+
const prefix = RawDataParser.stringValue(entry.limit_name) ?? RawDataParser.stringValue(entry.metered_feature) ?? 'Additional';
|
|
27
|
+
this.pushRateLimitWindows(windows, RawDataParser.asRecord(entry.rate_limit), prefix);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return windows.length > 0 ? windows : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private pushRateLimitWindows(windows: RateWindow[], rateLimit: Record<string, unknown> | undefined, prefix?: string): void {
|
|
34
|
+
this.pushRateWindow(windows, RawDataParser.asRecord(rateLimit?.primary_window), PRIMARY_WINDOW_FALLBACK_SECONDS, prefix);
|
|
35
|
+
this.pushRateWindow(windows, RawDataParser.asRecord(rateLimit?.secondary_window), SECONDARY_WINDOW_FALLBACK_SECONDS, prefix);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private pushRateWindow(windows: RateWindow[], window: Record<string, unknown> | undefined, fallbackWindowSeconds: number, prefix?: string): void {
|
|
39
|
+
if (!window) return;
|
|
40
|
+
|
|
41
|
+
const resetDate = this.getResetDate(window);
|
|
42
|
+
windows.push({
|
|
43
|
+
label: this.getWindowLabel(RawDataParser.numberValue(window.limit_window_seconds), fallbackWindowSeconds, prefix),
|
|
44
|
+
usedPercent: RawDataParser.numberValue(window.used_percent) ?? 0,
|
|
45
|
+
resetAt: resetDate,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getResetDate(window: Record<string, unknown>): Date | undefined {
|
|
50
|
+
const resetSeconds = RawDataParser.numberValue(window.reset_at);
|
|
51
|
+
if (!resetSeconds) return;
|
|
52
|
+
|
|
53
|
+
const resetDate = new Date(resetSeconds * 1000);
|
|
54
|
+
return Number.isFinite(resetDate.getTime()) ? resetDate : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private getWindowLabel(windowSeconds: number | undefined, fallbackWindowSeconds: number, prefix?: string): string {
|
|
58
|
+
const seconds = windowSeconds && windowSeconds > 0 ? windowSeconds : fallbackWindowSeconds;
|
|
59
|
+
const hours = Math.round(seconds / 3600);
|
|
60
|
+
|
|
61
|
+
const label = hours >= 144 ? 'Week' : hours >= 24 ? 'Day' : `${hours}h`;
|
|
62
|
+
return prefix ? `${prefix} ${label}` : label;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private request(opts: ProviderAuth) {
|
|
66
|
+
return new Request('https://chatgpt.com/backend-api/wham/usage', {
|
|
67
|
+
method: 'GET',
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${opts.token}`,
|
|
70
|
+
...(opts?.accountId ? { 'ChatGPT-Account-Id': opts.accountId } : {}),
|
|
71
|
+
Accept: 'application/json',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { PathUtil } from '../../utils/path.util';
|
|
3
|
+
import { RawDataParser } from '../../utils/raw-data-parser.util';
|
|
4
|
+
|
|
5
|
+
export type ProviderAuth = {
|
|
6
|
+
token: string;
|
|
7
|
+
accountId?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SubscriptionProvider = 'anthropic' | 'openai-codex';
|
|
11
|
+
type SubscriptionAuthconfig = Partial<Record<SubscriptionProvider, Record<string, unknown>>>;
|
|
12
|
+
|
|
13
|
+
export type RateWindow = {
|
|
14
|
+
label: string;
|
|
15
|
+
usedPercent: number;
|
|
16
|
+
resetAt?: Date;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type FetchUsageResponse = {
|
|
20
|
+
label: string;
|
|
21
|
+
rateWindow: RateWindow[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface SubscriptionUsageStrategy {
|
|
25
|
+
readonly provider: SubscriptionProvider;
|
|
26
|
+
readonly label: string;
|
|
27
|
+
fetchUsage(auth: ProviderAuth): Promise<RateWindow[] | undefined>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SubscriptionUsageApi {
|
|
31
|
+
async fetchUsage(strategy: SubscriptionUsageStrategy): Promise<FetchUsageResponse | undefined> {
|
|
32
|
+
const auth = this.getOauthProviderAuth(strategy.provider);
|
|
33
|
+
if (!auth) return;
|
|
34
|
+
const rateWindow = await strategy.fetchUsage(auth);
|
|
35
|
+
if (!rateWindow) return;
|
|
36
|
+
return {
|
|
37
|
+
label: strategy.label,
|
|
38
|
+
rateWindow,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
formatResetDescription(date: Date): string {
|
|
43
|
+
const diffMs = date.getTime() - Date.now();
|
|
44
|
+
if (diffMs <= 0) return 'now';
|
|
45
|
+
|
|
46
|
+
const minutes = Math.floor(diffMs / 60000);
|
|
47
|
+
if (minutes < 60) return `${minutes}m`;
|
|
48
|
+
|
|
49
|
+
const hours = Math.floor(minutes / 60);
|
|
50
|
+
const remainingMinutes = minutes % 60;
|
|
51
|
+
if (hours < 24) return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
|
|
52
|
+
|
|
53
|
+
const days = Math.floor(hours / 24);
|
|
54
|
+
const remainingHours = hours % 24;
|
|
55
|
+
return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private getOauthProviderAuth(provider: SubscriptionProvider): ProviderAuth | undefined {
|
|
59
|
+
const auth = this.loadAuthConfig();
|
|
60
|
+
if (!auth) return;
|
|
61
|
+
|
|
62
|
+
const providerAuth = auth[provider];
|
|
63
|
+
const access = RawDataParser.stringValue(providerAuth?.access);
|
|
64
|
+
if (!access) return;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
token: access,
|
|
68
|
+
accountId: RawDataParser.stringValue(providerAuth?.accountId),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private loadAuthConfig(): SubscriptionAuthconfig | undefined {
|
|
73
|
+
const authJson = PathUtil.findPiAuthConfig();
|
|
74
|
+
if (!authJson.exists) return undefined;
|
|
75
|
+
|
|
76
|
+
return RawDataParser.asRecord(JSON.parse(readFileSync(authJson.path, 'utf-8'))) as SubscriptionAuthconfig | undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
import { AutoSessionNameConfigSchema, PartialAutoSessionNameConfigSchema } from './auto-session-name.config.schema';
|
|
3
|
+
import { CustomFooterConfigSchema, DEFAULT_CUSTOM_FOOTER_CONFIG, PartialCustomFooterConfigSchema } from './custom-footer-config.schema';
|
|
3
4
|
import { ModelSelectConfigSchema, PartialModelSelectConfigSchema } from './model-select.config.schema';
|
|
4
5
|
|
|
5
6
|
export const ConfigSchema = z.object({
|
|
@@ -13,6 +14,7 @@ export const ConfigSchema = z.object({
|
|
|
13
14
|
provider_filter: [],
|
|
14
15
|
layout: 'inline',
|
|
15
16
|
}),
|
|
17
|
+
custom_footer: CustomFooterConfigSchema.default(DEFAULT_CUSTOM_FOOTER_CONFIG),
|
|
16
18
|
});
|
|
17
19
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
18
20
|
|
|
@@ -20,5 +22,6 @@ export const PartialConfigSchema = z.object({
|
|
|
20
22
|
$schema: z.string().optional(),
|
|
21
23
|
auto_session_name: PartialAutoSessionNameConfigSchema.optional(),
|
|
22
24
|
model_select: PartialModelSelectConfigSchema.optional(),
|
|
25
|
+
custom_footer: PartialCustomFooterConfigSchema.optional(),
|
|
23
26
|
});
|
|
24
27
|
export type PartialConfig = z.infer<typeof PartialConfigSchema>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { ColorHexSchema } from './shared-config.schema';
|
|
3
|
+
|
|
4
|
+
const CustomFooterColorsSchema = z.object({
|
|
5
|
+
directory: ColorHexSchema.optional(),
|
|
6
|
+
modelName: ColorHexSchema.optional(),
|
|
7
|
+
anthropicUsage: ColorHexSchema.optional().default('#D97706'),
|
|
8
|
+
codexUsage: ColorHexSchema.optional().default('#10B981'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const CustomFooterIconsSchema = z.object({
|
|
12
|
+
directory: z.string().optional().default(' '),
|
|
13
|
+
refresh: z.string().optional().default(''),
|
|
14
|
+
cache: z.string().optional().default(' '),
|
|
15
|
+
cacheRead: z.string().optional().default(' '),
|
|
16
|
+
cacheWrite: z.string().optional().default(' '),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const CustomFooterDisplaySchema = z.object({
|
|
20
|
+
tokens: z.boolean().optional().default(true),
|
|
21
|
+
cache: z.boolean().optional().default(true),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CUSTOM_FOOTER_COLORS = CustomFooterColorsSchema.parse({});
|
|
25
|
+
const DEFAULT_CUSTOM_FOOTER_ICONS = CustomFooterIconsSchema.parse({});
|
|
26
|
+
const DEFAULT_CUSTOM_FOOTER_DISPLAY = CustomFooterDisplaySchema.parse({});
|
|
27
|
+
|
|
28
|
+
export const CustomFooterConfigSchema = z.object({
|
|
29
|
+
enabled: z.boolean().default(false),
|
|
30
|
+
colors: CustomFooterColorsSchema.default(DEFAULT_CUSTOM_FOOTER_COLORS),
|
|
31
|
+
icons: CustomFooterIconsSchema.default(DEFAULT_CUSTOM_FOOTER_ICONS),
|
|
32
|
+
display: CustomFooterDisplaySchema.default(DEFAULT_CUSTOM_FOOTER_DISPLAY),
|
|
33
|
+
});
|
|
34
|
+
export type CustomFooterConfig = z.infer<typeof CustomFooterConfigSchema>;
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_CUSTOM_FOOTER_CONFIG = CustomFooterConfigSchema.parse({ enabled: false });
|
|
37
|
+
|
|
38
|
+
export const PartialCustomFooterConfigSchema = z.object({
|
|
39
|
+
enabled: z.boolean().optional(),
|
|
40
|
+
colors: z
|
|
41
|
+
.object({
|
|
42
|
+
directory: ColorHexSchema.optional(),
|
|
43
|
+
modelName: ColorHexSchema.optional(),
|
|
44
|
+
anthropicUsage: ColorHexSchema.optional(),
|
|
45
|
+
codexUsage: ColorHexSchema.optional(),
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
icons: z
|
|
49
|
+
.object({
|
|
50
|
+
directory: z.string().optional(),
|
|
51
|
+
refresh: z.string().optional(),
|
|
52
|
+
cache: z.string().optional(),
|
|
53
|
+
cacheRead: z.string().optional(),
|
|
54
|
+
cacheWrite: z.string().optional(),
|
|
55
|
+
})
|
|
56
|
+
.optional(),
|
|
57
|
+
display: z
|
|
58
|
+
.object({
|
|
59
|
+
tokens: z.boolean().optional(),
|
|
60
|
+
cache: z.boolean().optional(),
|
|
61
|
+
})
|
|
62
|
+
.optional(),
|
|
63
|
+
});
|
|
64
|
+
export type PartialCustomFooterConfig = z.infer<typeof PartialCustomFooterConfigSchema>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
|
+
import { COLOR_HEX_REGEX } from '../constants';
|
|
2
3
|
|
|
3
4
|
export const ReasoningLevelSchema = z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
4
5
|
export type ReasoningLevel = z.infer<typeof ReasoningLevelSchema>;
|
|
@@ -9,3 +10,5 @@ export const ModelConfigSchema = z.object({
|
|
|
9
10
|
reasoning: ReasoningLevelSchema,
|
|
10
11
|
});
|
|
11
12
|
export type ModelConfig = z.infer<typeof ModelConfigSchema>;
|
|
13
|
+
|
|
14
|
+
export const ColorHexSchema = z.string().regex(COLOR_HEX_REGEX, { message: 'Invalid color format. Must be a 7-character hex code (e.g., #RRGGBB).' });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { COLOR_HEX_REGEX } from '../constants';
|
|
2
|
+
|
|
3
|
+
type ColorizeOptions = {
|
|
4
|
+
bg?: string;
|
|
5
|
+
fg?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
|
9
|
+
return {
|
|
10
|
+
r: Number.parseInt(hex.slice(1, 3), 16),
|
|
11
|
+
g: Number.parseInt(hex.slice(3, 5), 16),
|
|
12
|
+
b: Number.parseInt(hex.slice(5, 7), 16),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fgAnsi(hex: string): string {
|
|
17
|
+
const { r, g, b } = hexToRgb(hex);
|
|
18
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function bgAnsi(hex: string): string {
|
|
22
|
+
const { r, g, b } = hexToRgb(hex);
|
|
23
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function colorize(text: string, { bg, fg }: ColorizeOptions = {}): string {
|
|
27
|
+
const hasFg = fg !== undefined && COLOR_HEX_REGEX.test(fg);
|
|
28
|
+
const hasBg = bg !== undefined && COLOR_HEX_REGEX.test(bg);
|
|
29
|
+
|
|
30
|
+
if (!hasFg && !hasBg) {
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const prefix = `${hasBg ? bgAnsi(bg) : ''}${hasFg ? fgAnsi(fg) : ''}`;
|
|
35
|
+
const suffix = `${hasFg ? '\x1b[39m' : ''}${hasBg ? '\x1b[49m' : ''}`;
|
|
36
|
+
|
|
37
|
+
return `${prefix}${text}${suffix}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ANSI_ESCAPE_REGEX = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g');
|
|
41
|
+
|
|
42
|
+
function stripAnsi(text: string): string {
|
|
43
|
+
return text.replace(ANSI_ESCAPE_REGEX, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const crayon = {
|
|
47
|
+
colorize(text: string, opts: ColorizeOptions = {}): string {
|
|
48
|
+
return colorize(text, opts);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
reverseVideo(text: string): string {
|
|
52
|
+
return `\x1b[7m${text}\x1b[27m`;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
stripAnsi(text: string): string {
|
|
56
|
+
return stripAnsi(text);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -7,6 +7,7 @@ export type ResolvedModel = {
|
|
|
7
7
|
apiKey?: string;
|
|
8
8
|
headers?: Record<string, string>;
|
|
9
9
|
reasoning?: ModelConfig['reasoning'];
|
|
10
|
+
isOauth?: boolean;
|
|
10
11
|
};
|
|
11
12
|
|
|
12
13
|
export type ResolveModelResult = {
|
|
@@ -50,6 +51,7 @@ export class ModelResolver {
|
|
|
50
51
|
apiKey: auth.apiKey,
|
|
51
52
|
headers: auth.headers,
|
|
52
53
|
reasoning: candidate.reasoning,
|
|
54
|
+
isOauth: this.ctx.modelRegistry.isUsingOAuth(model),
|
|
53
55
|
},
|
|
54
56
|
};
|
|
55
57
|
}
|
package/src/utils/path.util.ts
CHANGED
|
@@ -31,6 +31,10 @@ export class PathUtil {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
static findPiAuthConfig(): FileSearchResult {
|
|
35
|
+
return PathUtil.findFile(path.join(getAgentDir(), 'auth.json'));
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
private static getExtensionConfig(paths: string[]): string {
|
|
35
39
|
return path.join(...paths, 'extensions', EXTENSION_ID, 'config.json');
|
|
36
40
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class RawDataParser {
|
|
2
|
+
static asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
3
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
static stringValue(value: unknown): string | undefined {
|
|
7
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static numberValue(value: unknown): number | undefined {
|
|
11
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
12
|
+
return Number.isFinite(number) ? number : undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|