@0xkahi/pi-qol 0.0.1 → 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 +93 -12
- package/assets/config.schema.json +190 -2
- package/package.json +1 -1
- package/src/config-loader.ts +8 -4
- package/src/constants.ts +20 -0
- package/src/extensions/auto-session-name/index.ts +1 -1
- 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/extensions/model-select/constants.ts +8 -0
- package/src/extensions/model-select/index.ts +119 -0
- package/src/extensions/model-select/model-formatter.ts +60 -0
- package/src/extensions/model-select/model-lists.ts +63 -0
- package/src/extensions/model-select/model-select-dialog.ts +340 -0
- package/src/extensions/model-select/types.ts +31 -0
- package/src/index.ts +4 -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/auto-session-name.config.schema.ts +2 -2
- package/src/schemas/config.schema.ts +11 -0
- package/src/schemas/custom-footer-config.schema.ts +64 -0
- package/src/schemas/model-select.config.schema.ts +18 -0
- package/src/schemas/shared-config.schema.ts +5 -2
- package/src/types.ts +1 -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,20 @@
|
|
|
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
|
-
|
|
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.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Install the package from npm:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install npm:@0xkahi/pi-qol
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then register it in your pi configuration so pi loads `./src/index.ts` as an
|
|
19
|
+
extension (see the `pi.extensions` field in `package.json`).
|
|
8
20
|
|
|
9
21
|
## Features
|
|
10
22
|
|
|
@@ -23,16 +35,36 @@ never blocks your turn.
|
|
|
23
35
|
- **Safe output** — strips `<think>` blocks, unwraps quotes, and truncates titles
|
|
24
36
|
to 100 characters.
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
### `model_select`
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
Adds `/select-model`, an interactive model picker with fuzzy search, optional favourites,
|
|
41
|
+
provider filtering, and inline or overlay layouts,
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
```
|
|
43
|
+
- **quick access** to listed favourite models
|
|
44
|
+
- **filtering** specified providers from model search
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
allow keybindings with [pi-vim-keys](https://github.com/0xKahi/pi-vim-keys) with eventId of `pi.vimKeys.event:pi-qol.model_select`
|
|
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.
|
|
36
68
|
|
|
37
69
|
## Configuration
|
|
38
70
|
|
|
@@ -44,6 +76,7 @@ them (project overrides global):
|
|
|
44
76
|
| Global | `<agent-dir>/extensions/pi-qol/config.json` |
|
|
45
77
|
| Project | `<cwd>/.pi/extensions/pi-qol/config.json` (trusted projects only) |
|
|
46
78
|
|
|
79
|
+
|
|
47
80
|
> Project config is only loaded when the project is trusted.
|
|
48
81
|
|
|
49
82
|
### Example
|
|
@@ -54,9 +87,26 @@ them (project overrides global):
|
|
|
54
87
|
"auto_session_name": {
|
|
55
88
|
"enabled": true,
|
|
56
89
|
"model": {
|
|
57
|
-
"provider": "
|
|
58
|
-
"modelId": "
|
|
59
|
-
"reasoning": "
|
|
90
|
+
"provider": "opencode",
|
|
91
|
+
"modelId": "gpt-5-nano",
|
|
92
|
+
"reasoning": "low"
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"model_select": {
|
|
96
|
+
"enabled": true,
|
|
97
|
+
"layout": "overlay",
|
|
98
|
+
"provider_filter": ["anthropic", "openai"],
|
|
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
|
|
60
110
|
}
|
|
61
111
|
}
|
|
62
112
|
}
|
|
@@ -84,6 +134,35 @@ them (project overrides global):
|
|
|
84
134
|
When `model` is set but cannot be found or authenticated, `pi-qol` automatically
|
|
85
135
|
falls back to the active session model and surfaces a warning notification.
|
|
86
136
|
|
|
137
|
+
#### `model_select`
|
|
138
|
+
|
|
139
|
+
| Key | Type | Default | Description |
|
|
140
|
+
| ----------------- | --------- | -------- | --------------------------------------------------------- |
|
|
141
|
+
| `enabled` | `boolean` | `false` | Turn the model picker on. |
|
|
142
|
+
| `layout` | `enum` | `inline` | Picker layout: `inline` or `overlay`. |
|
|
143
|
+
| `provider_filter` | `string[]`| `[]` | Restrict searchable models to these providers. |
|
|
144
|
+
| `favourite` | `object[]`| `[]` | Favourite models shown in a separate tab (`provider`, `modelId`). |
|
|
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
|
+
|
|
163
|
+
Project config shallow-merges each top-level section over global config. If project
|
|
164
|
+
config sets `model_select.favourite`, it replaces the global favourites array.
|
|
165
|
+
|
|
87
166
|
## How it works
|
|
88
167
|
|
|
89
168
|
On `before_agent_start`, the `auto_session_name` extension checks a series of
|
|
@@ -122,6 +201,8 @@ src/
|
|
|
122
201
|
utils/ # path + model-resolution helpers
|
|
123
202
|
extensions/
|
|
124
203
|
auto-session-name/ # the auto_session_name feature
|
|
204
|
+
model-select/ # the model_select feature
|
|
205
|
+
custom-footer/ # the custom_footer feature
|
|
125
206
|
scripts/ # JSON schema generation
|
|
126
207
|
assets/config.schema.json # generated config schema
|
|
127
208
|
```
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/0xKahi/pi-qol/main/assets/config.schema.json",
|
|
4
|
-
"title": "Pi
|
|
4
|
+
"title": "Pi Quality of Life Extensions Configuration",
|
|
5
5
|
"description": "Configuration schema for pi-qol extension",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
@@ -51,10 +51,198 @@
|
|
|
51
51
|
"enabled"
|
|
52
52
|
],
|
|
53
53
|
"additionalProperties": false
|
|
54
|
+
},
|
|
55
|
+
"model_select": {
|
|
56
|
+
"default": {
|
|
57
|
+
"enabled": false,
|
|
58
|
+
"favourite": [],
|
|
59
|
+
"provider_filter": [],
|
|
60
|
+
"layout": "inline"
|
|
61
|
+
},
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"enabled": {
|
|
65
|
+
"default": false,
|
|
66
|
+
"type": "boolean"
|
|
67
|
+
},
|
|
68
|
+
"favourite": {
|
|
69
|
+
"default": [],
|
|
70
|
+
"type": "array",
|
|
71
|
+
"items": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"provider": {
|
|
75
|
+
"type": "string"
|
|
76
|
+
},
|
|
77
|
+
"modelId": {
|
|
78
|
+
"type": "string"
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"required": [
|
|
82
|
+
"provider",
|
|
83
|
+
"modelId"
|
|
84
|
+
],
|
|
85
|
+
"additionalProperties": false
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"provider_filter": {
|
|
89
|
+
"default": [],
|
|
90
|
+
"type": "array",
|
|
91
|
+
"items": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"minLength": 1
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"layout": {
|
|
97
|
+
"default": "inline",
|
|
98
|
+
"type": "string",
|
|
99
|
+
"enum": [
|
|
100
|
+
"inline",
|
|
101
|
+
"overlay"
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"required": [
|
|
106
|
+
"enabled",
|
|
107
|
+
"favourite",
|
|
108
|
+
"provider_filter",
|
|
109
|
+
"layout"
|
|
110
|
+
],
|
|
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
|
|
54
240
|
}
|
|
55
241
|
},
|
|
56
242
|
"required": [
|
|
57
|
-
"auto_session_name"
|
|
243
|
+
"auto_session_name",
|
|
244
|
+
"model_select",
|
|
245
|
+
"custom_footer"
|
|
58
246
|
],
|
|
59
247
|
"additionalProperties": false
|
|
60
248
|
}
|
package/package.json
CHANGED
package/src/config-loader.ts
CHANGED
|
@@ -11,10 +11,6 @@ export class ConfigLoader {
|
|
|
11
11
|
return ConfigSchema.parse({});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
getConfig(): Config {
|
|
15
|
-
return this.config;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
14
|
isEnabled(key: keyof Config): boolean {
|
|
19
15
|
const section = this.config[key];
|
|
20
16
|
return typeof section === 'object' && section !== null && 'enabled' in section ? Boolean((section as { enabled?: boolean }).enabled) : false;
|
|
@@ -24,6 +20,14 @@ export class ConfigLoader {
|
|
|
24
20
|
return this.config.auto_session_name;
|
|
25
21
|
}
|
|
26
22
|
|
|
23
|
+
getModelSelect(): Config['model_select'] {
|
|
24
|
+
return this.config.model_select;
|
|
25
|
+
}
|
|
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
|
@@ -1 +1,21 @@
|
|
|
1
|
+
import type { ObjectValues } from './types';
|
|
2
|
+
|
|
1
3
|
export const EXTENSION_ID = 'pi-qol';
|
|
4
|
+
|
|
5
|
+
const SUB_EXTENSION_IDS = {
|
|
6
|
+
auto_session_name: 'auto_session_name',
|
|
7
|
+
model_select: 'model_select',
|
|
8
|
+
custom_footer: 'custom_footer',
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type SubExtentionIds = ObjectValues<typeof SUB_EXTENSION_IDS>;
|
|
12
|
+
|
|
13
|
+
export const piVimKeyEventId = (type: SubExtentionIds, extra: string[] = []) => {
|
|
14
|
+
let id = `pi.vimKeys.event:${EXTENSION_ID}.${type}`;
|
|
15
|
+
extra.forEach(val => {
|
|
16
|
+
id = `${id}.${val}`;
|
|
17
|
+
});
|
|
18
|
+
return id;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const COLOR_HEX_REGEX = /^#[0-9a-fA-F]{6}$/;
|
|
@@ -66,7 +66,7 @@ export function registerAutoSessionName(pi: ExtensionAPI, deps: { config: Config
|
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
ctx.ui.notify(`(pi-qol)
|
|
69
|
+
ctx.ui.notify(`(pi-qol) session renamed : ${result.text} `, 'info');
|
|
70
70
|
})().catch(err => {
|
|
71
71
|
ctx.ui.notify(`(pi-qol) auto_session_name: ${JSON.stringify(err)}`, 'error');
|
|
72
72
|
});
|
|
@@ -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
|
+
}
|