@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.
Files changed (33) hide show
  1. package/README.md +93 -12
  2. package/assets/config.schema.json +190 -2
  3. package/package.json +1 -1
  4. package/src/config-loader.ts +8 -4
  5. package/src/constants.ts +20 -0
  6. package/src/extensions/auto-session-name/index.ts +1 -1
  7. package/src/extensions/custom-footer/constants.ts +4 -0
  8. package/src/extensions/custom-footer/footer-component.ts +179 -0
  9. package/src/extensions/custom-footer/index.ts +18 -0
  10. package/src/extensions/custom-footer/progress-bar.ts +16 -0
  11. package/src/extensions/custom-footer/subscription-usage-manager.ts +91 -0
  12. package/src/extensions/custom-footer/token-stats.ts +144 -0
  13. package/src/extensions/custom-footer/types.ts +40 -0
  14. package/src/extensions/model-select/constants.ts +8 -0
  15. package/src/extensions/model-select/index.ts +119 -0
  16. package/src/extensions/model-select/model-formatter.ts +60 -0
  17. package/src/extensions/model-select/model-lists.ts +63 -0
  18. package/src/extensions/model-select/model-select-dialog.ts +340 -0
  19. package/src/extensions/model-select/types.ts +31 -0
  20. package/src/index.ts +4 -0
  21. package/src/libs/subscription-usage/strategy/anthropic-oauth-usage.strategy.ts +41 -0
  22. package/src/libs/subscription-usage/strategy/openai-codex-usage.strategy.ts +75 -0
  23. package/src/libs/subscription-usage/subscription-usage-api.util.ts +78 -0
  24. package/src/schemas/auto-session-name.config.schema.ts +2 -2
  25. package/src/schemas/config.schema.ts +11 -0
  26. package/src/schemas/custom-footer-config.schema.ts +64 -0
  27. package/src/schemas/model-select.config.schema.ts +18 -0
  28. package/src/schemas/shared-config.schema.ts +5 -2
  29. package/src/types.ts +1 -0
  30. package/src/utils/crayon.util.ts +58 -0
  31. package/src/utils/model-resolver.util.ts +2 -0
  32. package/src/utils/path.util.ts +4 -0
  33. 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 — with
7
- more QoL features planned.
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
- ## Installation
38
+ ### `model_select`
27
39
 
28
- Install the package from npm:
40
+ Adds `/select-model`, an interactive model picker with fuzzy search, optional favourites,
41
+ provider filtering, and inline or overlay layouts,
29
42
 
30
- ```bash
31
- pi install npm:@0xkahi/pi-qol
32
- ```
43
+ - **quick access** to listed favourite models
44
+ - **filtering** specified providers from model search
33
45
 
34
- Then register it in your pi configuration so pi loads `./src/index.ts` as an
35
- extension (see the `pi.extensions` field in `package.json`).
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": "anthropic",
58
- "modelId": "claude-3-5-haiku-latest",
59
- "reasoning": "minimal"
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 Btw Extension Configuration",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xkahi/pi-qol",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "quality of life pi extensions",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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) auto_session_name: ${result.text}`, 'info');
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,4 @@
1
+ export const FILLED_BAR_ICON = '█';
2
+ export const EMPTY_BAR_ICON = '░';
3
+ export const SUBSCRIPTION_BAR_WIDTH = 10;
4
+ export const SUBSCRIPTION_USAGE_TTL_MS = 60_000;
@@ -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
+ }