@0xkahi/pi-qol 0.0.3 → 0.0.5

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.
@@ -43,14 +43,9 @@
43
43
  "provider",
44
44
  "modelId",
45
45
  "reasoning"
46
- ],
47
- "additionalProperties": false
46
+ ]
48
47
  }
49
- },
50
- "required": [
51
- "enabled"
52
- ],
53
- "additionalProperties": false
48
+ }
54
49
  },
55
50
  "model_select": {
56
51
  "default": {
@@ -81,8 +76,7 @@
81
76
  "required": [
82
77
  "provider",
83
78
  "modelId"
84
- ],
85
- "additionalProperties": false
79
+ ]
86
80
  }
87
81
  },
88
82
  "provider_filter": {
@@ -101,14 +95,7 @@
101
95
  "overlay"
102
96
  ]
103
97
  }
104
- },
105
- "required": [
106
- "enabled",
107
- "favourite",
108
- "provider_filter",
109
- "layout"
110
- ],
111
- "additionalProperties": false
98
+ }
112
99
  },
113
100
  "custom_footer": {
114
101
  "default": {
@@ -160,12 +147,7 @@
160
147
  "type": "string",
161
148
  "pattern": "^#[0-9a-fA-F]{6}$"
162
149
  }
163
- },
164
- "required": [
165
- "anthropicUsage",
166
- "codexUsage"
167
- ],
168
- "additionalProperties": false
150
+ }
169
151
  },
170
152
  "icons": {
171
153
  "default": {
@@ -197,15 +179,7 @@
197
179
  "default": " ",
198
180
  "type": "string"
199
181
  }
200
- },
201
- "required": [
202
- "directory",
203
- "refresh",
204
- "cache",
205
- "cacheRead",
206
- "cacheWrite"
207
- ],
208
- "additionalProperties": false
182
+ }
209
183
  },
210
184
  "display": {
211
185
  "default": {
@@ -222,27 +196,9 @@
222
196
  "default": true,
223
197
  "type": "boolean"
224
198
  }
225
- },
226
- "required": [
227
- "tokens",
228
- "cache"
229
- ],
230
- "additionalProperties": false
199
+ }
231
200
  }
232
- },
233
- "required": [
234
- "enabled",
235
- "colors",
236
- "icons",
237
- "display"
238
- ],
239
- "additionalProperties": false
201
+ }
240
202
  }
241
- },
242
- "required": [
243
- "auto_session_name",
244
- "model_select",
245
- "custom_footer"
246
- ],
247
- "additionalProperties": false
203
+ }
248
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xkahi/pi-qol",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "quality of life pi extensions",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/codemap.md ADDED
@@ -0,0 +1,60 @@
1
+ # src/
2
+
3
+ ## Responsibility
4
+
5
+ Top-level source root for `pi-qol`, a Pi coding-agent extension that layers small quality-of-life features onto the agent core.
6
+
7
+ It coordinates three feature modules:
8
+
9
+ - **auto_session_name**: Generates and applies a concise session title from the user's first message before the agent runs.
10
+ - **model_select**: Registers a `/select-model` command (plus a cross-extension event) for picking or searching available models with favourites and provider filtering.
11
+ - **custom_footer**: Replaces the default footer with a richer status bar showing cwd/branch/session, token/cost/cache stats, context-window usage, subscription-rate usage, and the current model/thinking level.
12
+
13
+ Shared concerns live here too: extension configuration loading, path resolution, model/auth resolution, output colorization, and Zod schemas for every feature.
14
+
15
+ ## Design Patterns
16
+
17
+ - **Plugin / Extension registration**: `index.ts` is a thin bootstrap that instantiates shared services and calls `register*` factory functions for each feature. Each feature registers itself against the `ExtensionAPI` event bus and command surface.
18
+ - **Event-driven lifecycle**: Features hook into `session_start`, `session_shutdown`, and `before_agent_start` events. They gate behaviour behind config flags and store transient state in closure variables (`lastSessionStartReason`, `titleController`, `installed`, `latestCtx`).
19
+ - **Configuration layering**: `ConfigLoader` merges a built-in default with a global JSON config and an optional project-level config, validated and typed with Zod. Only trusted projects load project-level overrides.
20
+ - **Strategy pattern for subscription usage**: `SubscriptionUsageManager` dispatches to provider-specific `SubscriptionUsageStrategy` implementations (`AnthropicOauthUsageStrategy`, `OpenAiCodexUsageStrategy`) behind a common `SubscriptionUsageApi` interface.
21
+ - **Component-based TUI**: `CustomFooterComponent` and `ModelSelectDialog` implement the host TUI's `Component`/`Focusable` interfaces, render to string arrays, and rely on the host for input routing and re-rendering.
22
+ - **Abortable async work**: `auto_session_name` tracks an `AbortController` across `before_agent_start` invocations so stale title requests are cancelled before a new one starts.
23
+ - **Utility classes as pure helpers**: `PathUtil`, `ModelResolver`, `ModelFormatter`, `AutoSessionNameGuard`, `AutoSessionNameTitleGenerator` are stateless or request-scoped helpers.
24
+
25
+ ## Data & Control Flow
26
+
27
+ 1. **Bootstrap** (`index.ts`)
28
+ - Creates `ConfigLoader`.
29
+ - On every `session_start`, calls `config.initializeConfig(ctx)`; surfaces validation errors via `ctx.ui.notify`.
30
+ - Registers the three feature modules, passing the shared loader.
31
+
32
+ 2. **Config loading** (`config-loader.ts`, `utils/path.util.ts`, `schemas/*.schema.ts`)
33
+ - Defaults → global `~/.pi/extensions/pi-qol/config.json` → project `.pi/extensions/pi-qol/config.json` (if trusted).
34
+ - Each partial config is shallow-merged per top-level key and re-validated by `ConfigSchema`.
35
+ - `PathUtil` owns the config/auth file path conventions.
36
+
37
+ 3. **auto_session_name** (`extensions/auto-session-name/`)
38
+ - Captures `session_start.reason`.
39
+ - On `before_agent_start`, guards check: enabled, no existing session name, not a child/fork session, and first user turn.
40
+ - `ModelResolver` picks the configured model or falls back to the session model, requiring resolvable auth.
41
+ - `AutoSessionNameTitleGenerator` builds a title-generation context, calls `completeSimple` from `pi-ai`, strips thinking tags/quotes, truncates to `MAX_TITLE_LENGTH`, and applies it via `pi.setSessionName`.
42
+
43
+ 4. **model_select** (`extensions/model-select/`)
44
+ - Lazily registers once on `session_start` when enabled.
45
+ - `/select-model [args]` handler refreshes the registry, attempts an exact provider/model match, otherwise opens a custom TUI dialog.
46
+ - `ModelSelectDialog` filters search items with fuzzy matching, navigates favourites/search sections, and returns the chosen model to `pi.setModel`.
47
+ - `PI_VIM_KEY_EVENT_ID` allows other extensions to trigger the same picker.
48
+
49
+ 5. **custom_footer** (`extensions/custom-footer/`)
50
+ - On first enabled `session_start`, installs a footer component via `ctx.ui.setFooter`.
51
+ - Each render reads live data from `ExtensionContext` (`sessionManager`, `modelRegistry`, `model`, `getContextUsage`, `footerData`), calculates session totals, formats tokens/cost/context usage, and optionally fetches subscription usage through cached strategies.
52
+
53
+ ## Integration Points
54
+
55
+ - **@earendil-works/pi-coding-agent**: Provides `ExtensionAPI`, `ExtensionContext`, `ExtensionCommandContext`, events (`session_start`, `session_shutdown`, `before_agent_start`), command registration (`pi.registerCommand`), UI notifications (`ctx.ui.notify`), footer injection (`ctx.ui.setFooter`), custom dialogs (`ctx.ui.custom`), session naming (`pi.setSessionName`/`getSessionName`), and model selection (`pi.setModel`/`getThinkingLevel`).
56
+ - **@earendil-works/pi-ai**: Used for `completeSimple`, `Model<Api>`, `modelsAreEqual`, and model-metadata types during title generation and model selection.
57
+ - **@earendil-works/pi-tui**: Provides the `Component`/`Focusable` contracts, `TUI`, `Theme`, `Input`, and text-width helpers (`truncateToWidth`, `visibleWidth`, `fuzzyFilter`) used by the footer and model picker.
58
+ - **Filesystem**: Reads global and project JSON configs and `~/.pi/auth.json` for OAuth tokens.
59
+ - **External provider APIs**: `SubscriptionUsageApi` and strategies call Anthropic/OpenAI Codex usage endpoints when OAuth auth is present.
60
+ - **Cross-extension events**: `piVimKeyEventId` from `constants.ts` lets other Pi extensions emit events that `model_select` listens on.
@@ -0,0 +1,48 @@
1
+ # src/extensions/auto-session-name/
2
+
3
+ ## Responsibility
4
+
5
+ Automatically generate and apply a short, descriptive name for a Pi session based on the user's opening message. The extension runs once per session, only on the user's first turn, and only when no session name has already been set and the session is not a child/fork. Generated titles are surfaced through the normal Pi session name API so they appear in the UI and are persisted with the session.
6
+
7
+ ## Design Patterns
8
+
9
+ - **Event-driven extension registration**: `registerAutoSessionName` attaches handlers to Pi lifecycle events (`session_start`, `before_agent_start`, `session_shutdown`) and coordinates state across them.
10
+ - **Guard object**: `AutoSessionNameGuard` isolates all preconditions (feature enabled check is kept in the caller) so the main flow reads as a simple chain of boolean decisions.
11
+ - **Static command object**: `AutoSessionNameTitleGenerator` encapsulates the entire LLM-backed title generation as a single static entry point with pure helper methods for extraction, cleaning, and truncation.
12
+ - **Prompt-as-data**: `prompt.ts` builds a `Context` value object containing a detailed system prompt and a single user message; no prompt logic leaks into the generator.
13
+ - **Dependency injection by structural typing**: All collaborators are accepted as `Pick<...>` or local interfaces, making the extension easy to unit test and minimizing coupling to the full ExtensionAPI surface.
14
+ - **Cancellation token**: A module-level `AbortController` is created per title request and aborted on `session_shutdown` or when a newer request starts, preventing stale completions from overwriting the session name.
15
+ - **Defensive post-condition check**: After applying the title, the code compares `pi.getSessionName()` with the generated text and warns if they diverge.
16
+
17
+ ## Data & Control Flow
18
+
19
+ 1. **Session start**: The `session_start` handler records `event.reason` in `lastSessionStartReason`.
20
+ 2. **Agent start trigger**: On `before_agent_start`, the handler first verifies:
21
+ - `auto_session_name` is enabled in config.
22
+ - No session name is currently set (`getSessionName() !== undefined`).
23
+ - The session is not a child/fork (`startReason !== 'fork'` and no `parentSession` header).
24
+ - This is the user's first turn (zero prior user messages in the branch).
25
+ - The current user prompt is non-empty.
26
+ 3. **Model resolution**: `ModelResolver` resolves the configured title model into a concrete `ResolvedModel` (model, API key, headers, reasoning preference). Errors are surfaced via `ctx.ui.notify`.
27
+ 4. **Title generation**: `AutoSessionNameTitleGenerator.generateAndApplyTitle` sends a single-turn completion to the LLM using the context from `buildTitleContext`.
28
+ 5. **Title cleaning**: The raw response has `<think>` blocks stripped, is reduced to the first non-empty line, has surrounding quotes removed, and is truncated to `MAX_TITLE_LENGTH`.
29
+ 6. **Application**: The cleaned title is applied via `pi.setSessionName`. If application fails or returns a different value, a warning is shown.
30
+ 7. **Feedback**: Success or failure is reported through `ctx.ui.notify`.
31
+ 8. **Cancellation/reset**: `session_shutdown` aborts any pending request and clears the controller.
32
+
33
+ ## Integration Points
34
+
35
+ - **Pi ExtensionAPI**:
36
+ - Events: `session_start`, `session_shutdown`, `before_agent_start`.
37
+ - Methods: `getSessionName`, `setSessionName`.
38
+ - **Pi Context/session manager** (`ctx.sessionManager`):
39
+ - `getBranch()` and `getHeader()` are used to detect first user turn and child sessions.
40
+ - **ConfigLoader** (`../../config-loader`):
41
+ - `isEnabled('auto_session_name')` toggles the feature.
42
+ - `getAutoSessionName()` supplies the optional per-feature model override.
43
+ - **ModelResolver** (`../../utils/model-resolver.util`):
44
+ - Resolves the configured model string into a full `ResolvedModel` usable by `pi-ai`.
45
+ - **pi-ai** (`@earendil-works/pi-ai`):
46
+ - `completeSimple` performs the actual LLM completion.
47
+ - **Pi UI notifications** (`ctx.ui.notify`):
48
+ - All user-facing status, warnings, and errors are emitted through the Pi notification channel.
@@ -0,0 +1,59 @@
1
+ # src/extensions/
2
+
3
+ ## Responsibility
4
+
5
+ This folder contains the feature-level extension modules for `pi-qol`. Each child module registers one self-contained quality-of-life feature into the Pi Coding Agent runtime by subscribing to lifecycle events, commands, and UI hooks exposed by the `ExtensionAPI`.
6
+
7
+ Current features:
8
+
9
+ - `auto-session-name` – automatically generates and applies a concise session title from the user’s first message.
10
+ - `model-select` – provides an interactive `/select-model` command (and cross-extension activation hook) for choosing models from the registry with favourites, search, and provider filtering.
11
+ - `custom-footer` – replaces the TUI footer with a richer status bar showing cwd, git branch, session name, model, token/cost usage, context-window usage, and subscription usage.
12
+
13
+ ## Design Patterns
14
+
15
+ - **Registration function pattern**: each module exports a single `register*` function that takes `ExtensionAPI` and a `{ config: ConfigLoader }` dependency object. `src/index.ts` calls all three registration functions after initializing configuration.
16
+ - **Event-driven lifecycle wiring**: registration is non-blocking; actual work is triggered by Pi runtime events (`session_start`, `session_shutdown`, `before_agent_start`).
17
+ - **Lazy / once-only activation**: `model-select` and `custom-footer` use guard flags (`modelSelectRegistered`, `installed`) to ensure handlers or UI components are registered only once per process.
18
+ - **Command registration**: `model-select` registers a slash command via `pi.registerCommand` and defers heavy work to an async handler.
19
+ - **TUI component pattern**: `custom-footer` constructs a `Component` that renders live on each frame, invalidating Pi’s default footer rendering.
20
+ - **Guard classes**: `auto-session-name/guards.ts` isolates predicate logic (session name already set, child session, first user turn) for easy testing and readable early returns.
21
+ - **Pure helpers / formatters**: `model-formatter.ts`, `token-stats.ts`, `model-lists.ts`, and `prompt.ts` keep transformation and rendering logic free of side effects.
22
+ - **Cross-extension event bus**: `model-select` listens on a dedicated `pi.events` channel (`PI_VIM_KEY_EVENT_ID`) so other extensions can open the picker without invoking the slash command directly.
23
+ - **Abortable async work**: `auto-session-name` keeps an `AbortController` and cancels in-flight title generation on `session_shutdown` or a new `before_agent_start`.
24
+
25
+ ## Data & Control Flow
26
+
27
+ 1. **Configuration load** (`src/index.ts`)
28
+ - On `session_start`, `ConfigLoader.initializeConfig(ctx)` merges global and (if trusted) project JSON configs, validated by Zod schemas.
29
+ - All enabled checks and section values are read through `ConfigLoader`.
30
+
31
+ 2. **auto-session-name flow**
32
+ - Records `lastSessionStartReason` on `session_start`.
33
+ - On `before_agent_start`, guards verify the feature is enabled, the session has no name yet, it is not a child session, and this is the user’s first turn.
34
+ - Resolves the configured title-generation model via `ModelResolver`.
35
+ - Calls `completeSimple` with a small title-generation context built by `prompt.ts`.
36
+ - Cleans the result, calls `pi.setSessionName(title)`, and notifies the UI.
37
+ - Aborts any previous in-flight request before starting a new one.
38
+
39
+ 3. **model-select flow**
40
+ - Lazy-activated once on `session_start` if enabled.
41
+ - Command handler calls `waitForIdle` (when available), refreshes `ctx.modelRegistry`, attempts an exact provider/model match from arguments, then falls back to an interactive `ModelSelectDialog`.
42
+ - The dialog uses fuzzy filtering, tab switching between favourites/search, and keyboard navigation; selection is applied via `pi.setModel`.
43
+ - The event-bus handler captures the latest context so the picker can also be opened without a command context.
44
+
45
+ 4. **custom-footer flow**
46
+ - On `session_start`, installs a `CustomFooterComponent` factory via `ctx.ui.setFooter`.
47
+ - Each render reads from `ExtensionContext` (cwd, session name, model, context usage, entries), `FooterDataProvider` (git branch, extension statuses, available providers), and a cached `SubscriptionUsageManager`.
48
+ - The component builds and truncates lines to the available terminal width and requests re-renders via `tui.requestRender()` when branch changes or subscription usage updates.
49
+
50
+ ## Integration Points
51
+
52
+ - **`@earendil-works/pi-coding-agent`** – `ExtensionAPI`, `ExtensionContext`, `ExtensionCommandContext`, events, commands, session manager, and UI notifications.
53
+ - **`@earendil-works/pi-ai`** – model types (`Model`, `Api`), `completeSimple`, and `modelsAreEqual`.
54
+ - **`@earendil-works/pi-tui`** – `Component`, `Focusable`, `Input`, `TUI`, `Theme`, `KeybindingsManager`, and width helpers.
55
+ - **`src/config-loader.ts`** – shared `ConfigLoader` used by all three registration functions for feature flags and section config.
56
+ - **`src/utils/model-resolver.util.ts`** – resolves provider/model/auth configuration for `auto-session-name`.
57
+ - **`src/libs/subscription-usage/`** – `SubscriptionUsageApi` and provider-specific strategies used by the footer to fetch OAuth subscription usage.
58
+ - **`src/constants.ts` / `piVimKeyEventId`** – generates the cross-extension event id consumed by `model-select`.
59
+ - **`src/index.ts`** – orchestrates config initialization and calls each `register*` function in a fixed order.
@@ -0,0 +1,49 @@
1
+ # src/extensions/custom-footer/
2
+
3
+ ## Responsibility
4
+
5
+ Renders a custom TUI footer that replaces the default agent footer. It displays:
6
+
7
+ - The current working directory, git branch, and session name.
8
+ - Token usage totals (input/output) for assistant messages in the current session.
9
+ - Cache read/write totals and the latest cache hit rate.
10
+ - Estimated session cost, with a subscription indicator when using OAuth models.
11
+ - Context window usage as a percentage of the active model's context window.
12
+ - OAuth subscription usage (Anthropic / OpenAI Codex) including a progress bar, percentage, and reset description.
13
+ - The active model name and reasoning/thinking level.
14
+ - Sorted, sanitized extension status messages.
15
+
16
+ The extension is feature-gated by the `custom_footer` config flag and can dynamically restore the default footer when disabled.
17
+
18
+ ## Design Patterns
19
+
20
+ - **Plugin registration**: `index.ts` listens to the agent's `session_start` event and registers a single footer component factory via `ctx.ui.setFooter`.
21
+ - **Component pattern**: `CustomFooterComponent` implements the `Component` interface (`render`, `invalidate`, `dispose`) so the TUI can call it on every frame.
22
+ - **Dependency injection**: The component receives its dependencies (`tui`, `theme`, `footerData`, `ctx`, `config`, `getThinkingLevel`) in a single `CustomFooterComponentDeps` object, keeping rendering logic decoupled from construction.
23
+ - **Data provider abstraction**: `FooterDataProvider` isolates footer-specific data (git branch, extension statuses, provider count, branch-change notifications) from the broader `ExtensionContext`.
24
+ - **Cache manager**: `SubscriptionUsageManager` caches OAuth usage responses with a TTL and coalesces in-flight requests per provider.
25
+ - **Strategy pattern**: Provider-specific OAuth usage fetch strategies (`AnthropicOauthUsageStrategy`, `OpenAiCodexUsageStrategy`) are selected at runtime inside `SubscriptionUsageManager`.
26
+ - **Pure formatting helpers**: `token-stats.ts` and `progress-bar.ts` contain stateless functions for number formatting, usage aggregation, and progress-bar rendering, making them easy to test.
27
+
28
+ ## Data & Control Flow
29
+
30
+ 1. **Registration**: `registerCustomFooter` waits for `session_start`, checks the `custom_footer` feature flag, and installs `CustomFooterComponent` as the footer renderer. It guards against double installation.
31
+ 2. **Rendering trigger**: The TUI calls `CustomFooterComponent.render(width)` on every footer repaint.
32
+ 3. **Dynamic disable**: If `custom_footer` is disabled at render time, the component calls `ctx.ui.setFooter(undefined)` once and returns an empty array so the default footer is restored.
33
+ 4. **Line assembly**: `render` builds up to three lines:
34
+ - `renderPathLine`: directory, branch, session name.
35
+ - `renderStatsLine`: token/cost/context/subscription stats plus the model name.
36
+ - Extension status line (only when statuses exist).
37
+ 5. **Stats composition**: `buildStatsLeft` aggregates assistant-message usage from `sessionManager.getEntries()`, formats tokens, adds cache info, cost, context usage, and an optional subscription segment.
38
+ 6. **Subscription usage**: `renderSubscriptionUsageSegment` resolves the provider, asks `SubscriptionUsageManager.ensureFresh(provider)` for cached data, picks the rate window with the nearest reset, and builds a colored progress segment.
39
+ 7. **Async refresh**: `SubscriptionUsageManager` fetches usage asynchronously when the cached entry is older than `SUBSCRIPTION_USAGE_TTL_MS`. On success it calls `tui.requestRender()` via the `onUpdate` callback to update the footer.
40
+ 8. **Git branch updates**: `footerData.onBranchChange` registers a render request callback that is cleaned up in `dispose`.
41
+
42
+ ## Integration Points
43
+
44
+ - **`@earendil-works/pi-coding-agent`**: Uses `ExtensionAPI` (event subscription, `getThinkingLevel`), `ExtensionContext` (`model`, `modelRegistry`, `sessionManager`, `getContextUsage`, `ui.setFooter`), and `ContextUsage` / `SessionEntry` types.
45
+ - **`@earendil-works/pi-tui`**: Implements the `Component` contract and uses `truncateToWidth`, `visibleWidth`, and `TUI.requestRender`.
46
+ - **`../../config-loader`**: Reads the `custom_footer` feature flag and full footer config (colors, icons, display toggles) via `ConfigLoader`.
47
+ - **`../../schemas/config.schema`**: `CustomFooterConfig` is derived from the `Config` schema's `custom_footer` shape.
48
+ - **`../../utils/crayon.util`**: Applies custom ANSI colors to directory text, model name, and subscription usage labels.
49
+ - **`../../libs/subscription-usage/**`**: Delegates OAuth usage fetching and reset formatting to `SubscriptionUsageApi` and provider-specific strategies.
@@ -0,0 +1,59 @@
1
+ # src/extensions/model-select/
2
+
3
+ ## Responsibility
4
+
5
+ This extension implements the `/select-model` command and its interactive picker. Its job is to let the user search, browse, and choose a model from the global model registry, optionally constrained by a provider filter and a configured list of favourites. It highlights the currently active model, supports keyboard navigation, and applies the final selection by calling the extension API. It can be triggered both as a user-facing command and programmatically from another extension (e.g., `pi-vim-keys`) via an event-bus hook.
6
+
7
+ ## Design Patterns
8
+
9
+ - **Lazy command activation**: `registerModelSelect` waits for the first `session_start` event before calling `activateModelSelect`, ensuring the extension is wired up once a session exists.
10
+ - **Command + event-bus controller**: `activateModelSelect` registers the `/select-model` command and also listens on a cross-extension event ID so external extensions can open the picker without invoking the command directly.
11
+ - **Context caching for async triggers**: The most recent `ExtensionContext` is stored (`latestCtx`) so the event-bus handler (which receives no context argument) can still open the dialog.
12
+ - **Separation of concerns**:
13
+ - `index.ts`: orchestrates activation, command handling, and applying the selected model.
14
+ - `model-lists.ts`: queries the registry and prepares favourite + searchable model lists.
15
+ - `model-formatter.ts`: pure static utilities for labels, descriptions, sorting, and token formatting.
16
+ - `model-select-dialog.ts`: TUI component that renders the picker and translates user input into selection events.
17
+ - **TUI Component pattern**: `ModelSelectDialog` implements `Component` and `Focusable`, rendering itself into lines and delegating focus to an internal `Input` component.
18
+ - **Defensive guards**: checks feature enablement, UI availability, idle state (`waitForIdle`), exact-match short-circuit, and auth availability before applying a model.
19
+
20
+ ## Data & Control Flow
21
+
22
+ 1. **Activation**
23
+ - `registerModelSelect` listens for `session_start`.
24
+ - On first eligible session, it calls `activateModelSelect`, which stores the initial context and registers the `/select-model` command and the `PI_VIM_KEY_EVENT_ID` listener.
25
+
26
+ 2. **Command invocation**
27
+ - The command handler stores the command context as `latestCtx`.
28
+ - It aborts if `model_select` is disabled.
29
+ - It calls `showModelSelector` with the raw argument string.
30
+
31
+ 3. **Selection short-circuit**
32
+ - `showModelSelector` waits for idle if available, then refreshes the model registry.
33
+ - If the argument can be parsed as `provider/modelId` and resolves through `findExactModel`, the model is applied immediately and the dialog is skipped.
34
+
35
+ 4. **Dialog path**
36
+ - If no exact match is found and a UI is present, the extension loads the `model_select` config.
37
+ - `buildModelLists` refreshes the registry, optionally filters by `provider_filter`, sorts search items with the current model first, and validates configured favourites (collecting warnings for missing models or missing auth).
38
+ - `ctx.ui.custom` creates a `ModelSelectDialog` with the prepared lists, current model, warnings, layout, and an `onDone` callback.
39
+ - The dialog renders two sections (Favourites and Search), a search input, config warnings, and a help footer.
40
+
41
+ 5. **User interaction**
42
+ - Keyboard input is mapped through `keybindings.matches` for navigation, section switching, confirmation, and cancellation.
43
+ - Typing printable characters switches focus to the search input and re-filters the search list via `fuzzyFilter`.
44
+ - Confirming a selection invokes `onDone` with the chosen `Model`.
45
+
46
+ 6. **Apply result**
47
+ - If a model is selected, `applySelectedModel` calls `pi.setModel`.
48
+ - A success or missing-auth notification is shown via `ctx.ui.notify`.
49
+
50
+ ## Integration Points
51
+
52
+ - **`ExtensionAPI` (`@earendil-works/pi-coding-agent`)**: used to register the command, listen to `session_start`, set the active model (`pi.setModel`), and access the shared event bus (`pi.events`).
53
+ - **`ExtensionContext` / `ExtensionCommandContext` (`@earendil-works/pi-coding-agent`)**: provides `modelRegistry`, `ui`, `hasUI`, `model`, and the optional `waitForIdle` guard.
54
+ - **`ModelRegistry`**: refreshed and queried for available models, configured auth, and lookup by `provider` + `modelId`.
55
+ - **`ConfigLoader` (`../../config-loader`)**: provides feature toggle state (`isEnabled('model_select')`) and the typed `model_select` configuration object.
56
+ - **`@earendil-works/pi-ai`**: supplies the `Model<Api>` type and `modelsAreEqual` for stable identity comparisons.
57
+ - **`@earendil-works/pi-tui`**: supplies the `Input` component, `fuzzyFilter`, theme helpers, and rendering utilities (`truncateToWidth`, `visibleWidth`) used by the dialog.
58
+ - **`pi-vim-keys` / other extensions**: can trigger the picker by emitting `PI_VIM_KEY_EVENT_ID` on the shared event bus.
59
+ - **Configuration schemas**: `model_select` shape and `ModelSelectLayout` are defined by `../../schemas/config.schema` and `../../schemas/model-select.config.schema`.
@@ -5,22 +5,13 @@ import { MAX_CONFIG_WARNING_LINES, MAX_VISIBLE_MODELS } from './constants';
5
5
  import { ModelFormatter } from './model-formatter';
6
6
  import type { DialogOptions, ModelItem, SelectionSection } from './types';
7
7
 
8
- function isPrintableInput(data: string): boolean {
9
- if (data.length === 0) {
10
- return false;
11
- }
12
- return [...data].every(char => {
13
- const code = char.charCodeAt(0);
14
- return code >= 32 && code !== 0x7f && (code < 0x80 || code > 0x9f);
15
- });
16
- }
17
-
18
8
  export class ModelSelectDialog implements Component, Focusable {
19
9
  private readonly searchInput = new Input();
20
10
  private activeSection: SelectionSection;
21
11
  private selectedFavouriteIndex = 0;
22
12
  private selectedSearchIndex = 0;
23
13
  private filteredSearchItems: ModelItem[];
14
+ private filteredFavouriteItems: ModelItem[];
24
15
  private _focused = false;
25
16
 
26
17
  constructor(
@@ -35,9 +26,10 @@ export class ModelSelectDialog implements Component, Focusable {
35
26
  : -1;
36
27
  this.selectedFavouriteIndex = currentFavouriteIndex >= 0 ? currentFavouriteIndex : 0;
37
28
  this.searchInput.setValue(options.initialSearch);
38
- this.searchInput.onSubmit = () => this.selectCurrentSearchItem();
29
+ this.searchInput.onSubmit = () => this.selectCurrentItem();
39
30
  this.searchInput.onEscape = () => this.options.onDone(null);
40
- this.filteredSearchItems = this.filterSearchItems(options.initialSearch);
31
+ this.filteredSearchItems = this.filterItems(options.searchItems, options.initialSearch);
32
+ this.filteredFavouriteItems = this.filterItems(options.favouriteItems, options.initialSearch);
41
33
  this.syncFocus();
42
34
  }
43
35
 
@@ -95,17 +87,16 @@ export class ModelSelectDialog implements Component, Focusable {
95
87
  return;
96
88
  }
97
89
 
98
- if (this.activeSection !== 'search' && isPrintableInput(data)) {
99
- this.activeSection = 'search';
100
- this.syncFocus();
101
- }
90
+ this.searchInput.handleInput(data);
91
+ this.applyFilter(this.searchInput.getValue());
92
+ this.tui.requestRender();
93
+ }
102
94
 
103
- if (this.activeSection === 'search') {
104
- this.searchInput.handleInput(data);
105
- this.filteredSearchItems = this.filterSearchItems(this.searchInput.getValue());
106
- this.selectedSearchIndex = Math.min(this.selectedSearchIndex, Math.max(0, this.filteredSearchItems.length - 1));
107
- this.tui.requestRender();
108
- }
95
+ private applyFilter(query: string): void {
96
+ this.filteredSearchItems = this.filterItems(this.options.searchItems, query);
97
+ this.filteredFavouriteItems = this.filterItems(this.options.favouriteItems, query);
98
+ this.selectedSearchIndex = Math.min(this.selectedSearchIndex, Math.max(0, this.filteredSearchItems.length - 1));
99
+ this.selectedFavouriteIndex = Math.min(this.selectedFavouriteIndex, Math.max(0, this.filteredFavouriteItems.length - 1));
109
100
  }
110
101
 
111
102
  render(width: number): string[] {
@@ -179,12 +170,12 @@ export class ModelSelectDialog implements Component, Focusable {
179
170
  }
180
171
 
181
172
  private syncFocus(): void {
182
- this.searchInput.focused = this._focused && this.activeSection === 'search';
173
+ this.searchInput.focused = this._focused;
183
174
  }
184
175
 
185
176
  private moveSelection(delta: number): void {
186
177
  if (this.activeSection === 'favourites') {
187
- this.selectedFavouriteIndex = this.wrapIndex(this.selectedFavouriteIndex + delta, this.options.favouriteItems.length);
178
+ this.selectedFavouriteIndex = this.wrapIndex(this.selectedFavouriteIndex + delta, this.filteredFavouriteItems.length);
188
179
  return;
189
180
  }
190
181
 
@@ -200,7 +191,7 @@ export class ModelSelectDialog implements Component, Focusable {
200
191
 
201
192
  private selectCurrentItem(): void {
202
193
  if (this.activeSection === 'favourites') {
203
- const item = this.options.favouriteItems[this.selectedFavouriteIndex];
194
+ const item = this.filteredFavouriteItems[this.selectedFavouriteIndex];
204
195
  if (item) {
205
196
  this.options.onDone(item.model);
206
197
  }
@@ -217,12 +208,12 @@ export class ModelSelectDialog implements Component, Focusable {
217
208
  }
218
209
  }
219
210
 
220
- private filterSearchItems(query: string): ModelItem[] {
211
+ private filterItems(items: ModelItem[], query: string): ModelItem[] {
221
212
  const trimmed = query.trim();
222
213
  if (!trimmed) {
223
- return this.options.searchItems;
214
+ return items;
224
215
  }
225
- return fuzzyFilter(this.options.searchItems, trimmed, item => item.searchText);
216
+ return fuzzyFilter(items, trimmed, item => item.searchText);
226
217
  }
227
218
 
228
219
  private renderTitle(): string {
@@ -233,9 +224,9 @@ export class ModelSelectDialog implements Component, Focusable {
233
224
  private renderTabs(): string {
234
225
  const tabs: string[] = [];
235
226
  if (this.options.hasFavouriteSection) {
236
- tabs.push(this.renderTab('favourites', `Favourites ${this.options.favouriteItems.length}`));
227
+ tabs.push(this.renderTab('favourites', `Favourites ${this.filteredFavouriteItems.length}`));
237
228
  }
238
- tabs.push(this.renderTab('search', `Search ${this.options.searchItems.length}`));
229
+ tabs.push(this.renderTab('search', `Search ${this.filteredSearchItems.length}`));
239
230
  return tabs.join(this.theme.fg('muted', ' '));
240
231
  }
241
232
 
@@ -250,11 +241,17 @@ export class ModelSelectDialog implements Component, Focusable {
250
241
  private renderFavourites(width: number): string[] {
251
242
  const lines: string[] = [];
252
243
 
244
+ lines.push(this.line(this.theme.fg('muted', 'Filter:'), width));
245
+ lines.push(...this.searchInput.render(width));
246
+ lines.push('');
247
+
253
248
  if (this.options.favouriteItems.length === 0) {
254
249
  lines.push(this.line(this.theme.fg('muted', ' No configured favourites are available.'), width));
250
+ } else if (this.filteredFavouriteItems.length === 0) {
251
+ lines.push(this.line(this.theme.fg('muted', ' No matching favourites'), width));
255
252
  } else {
256
- lines.push(...this.renderModelList(this.options.favouriteItems, this.selectedFavouriteIndex, width));
257
- const selected = this.options.favouriteItems[this.selectedFavouriteIndex];
253
+ lines.push(...this.renderModelList(this.filteredFavouriteItems, this.selectedFavouriteIndex, width));
254
+ const selected = this.filteredFavouriteItems[this.selectedFavouriteIndex];
258
255
  if (selected) {
259
256
  lines.push('');
260
257
  lines.push(this.line(this.theme.fg('muted', ` ${selected.description}`), width));
@@ -0,0 +1,33 @@
1
+ # src/libs/
2
+
3
+ ## Responsibility
4
+
5
+ `src/libs/` hosts shared, domain-specific library modules that are consumed by extensions and features across the project. It is not a generic utilities folder; modules here encapsulate cohesive business capabilities with stable public APIs.
6
+
7
+ Currently, the folder contains the `subscription-usage` module, whose job is to fetch and normalize subscription/rate-limit usage data from upstream OAuth providers (Anthropic, OpenAI Codex) so that UI code can display a consistent usage indicator without needing to know provider-specific API details.
8
+
9
+ ## Design Patterns
10
+
11
+ - **Strategy pattern** — A provider-agnostic `SubscriptionUsageStrategy` interface is implemented by per-provider strategies (`AnthropicOauthUsageStrategy`, `OpenAiCodexUsageStrategy`). This keeps provider-specific request building, response parsing, and window mapping isolated and makes adding new providers a matter of adding another strategy.
12
+ - **API facade** — `SubscriptionUsageApi` presents a single `fetchUsage(strategy)` entry point. It handles credential lookup and delegates the provider-specific call to the supplied strategy, returning a normalized `FetchUsageResponse`.
13
+ - **Adapter/normalization** — Each strategy adapts a different upstream API shape into the common `RateWindow[]` model (`label`, `usedPercent`, `resetAt`), so consumers operate on one uniform data structure.
14
+ - **Defensive parsing** — Strategies use `RawDataParser` to coerce unknown provider JSON into typed records/values, avoiding runtime exceptions from unexpected payload shapes.
15
+ - **Config-driven auth** — `SubscriptionUsageApi` reads provider tokens (and optional account IDs) from a local pi auth config file rather than accepting credentials directly.
16
+
17
+ ## Data & Control Flow
18
+
19
+ 1. A consumer (e.g., `SubscriptionUsageManager`) selects a provider and asks `SubscriptionUsageApi.fetchUsage()` to load usage data for that provider's strategy.
20
+ 2. `SubscriptionUsageApi` loads the auth config via `PathUtil.findPiAuthConfig()`, extracts the token/account for the strategy's provider, and bails out gracefully if no auth is available.
21
+ 3. The strategy builds its provider-specific HTTP request, calls the upstream endpoint, and parses the response.
22
+ 4. The strategy maps the provider payload into `RateWindow[]` and returns it.
23
+ 5. `SubscriptionUsageApi` wraps the windows with the strategy's human-readable `label` into a `FetchUsageResponse`.
24
+ 6. Consumers can use `SubscriptionUsageApi.formatResetDescription(date)` to turn a `resetAt` timestamp into a short relative string such as `15m`, `2h30m`, or `1d`.
25
+
26
+ ## Integration Points
27
+
28
+ - **Consumers** — `src/extensions/custom-footer/subscription-usage-manager.ts` imports the strategies and `SubscriptionUsageApi`, caches responses, picks the most relevant window, and drives UI updates.
29
+ - **Auth/config** — `src/utils/path.util.ts` (`PathUtil.findPiAuthConfig()`) locates the local auth file; the API expects provider entries keyed by `SubscriptionProvider` names.
30
+ - **Parsing utility** — `src/utils/raw-data-parser.util.ts` is used across strategies to safely read fields from dynamic JSON.
31
+ - **External services** — Strategies call live provider endpoints:
32
+ - Anthropic: `https://api.anthropic.com/api/oauth/usage`
33
+ - OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
@@ -0,0 +1,28 @@
1
+ # src/libs/subscription-usage/
2
+
3
+ ## Responsibility
4
+
5
+ Encapsulates fetching and normalizing subscription usage (rate-limit utilization) from external AI providers into a common `RateWindow` representation. It is the single place the system queries provider-specific usage APIs and turns their heterogeneous responses into a consistent, UI-friendly shape.
6
+
7
+ ## Design Patterns
8
+
9
+ - **Strategy**: `SubscriptionUsageApi` consumes a `SubscriptionUsageStrategy` (`AnthropicOauthUsageStrategy`, `OpenAiCodexUsageStrategy`) without knowing provider-specific details.
10
+ - **Adapter / Normalization**: each strategy adapts a provider response to the shared `RateWindow[]` model (`label`, `usedPercent`, `resetAt`).
11
+ - **Safe parsing**: `RawDataParser` guards against unexpected JSON shapes and missing fields.
12
+ - **Utility API class**: `SubscriptionUsageApi` wraps auth loading, fetching, and formatting in one injectable-style class.
13
+
14
+ ## Data & Control Flow
15
+
16
+ 1. A caller invokes `SubscriptionUsageApi.fetchUsage(strategy)`.
17
+ 2. The API loads provider OAuth credentials from the local auth config via `PathUtil.findPiAuthConfig()`.
18
+ 3. It calls `strategy.fetchUsage(auth)`.
19
+ 4. The strategy performs a provider HTTP request, parses JSON, and maps relevant fields to `RateWindow[]`.
20
+ 5. The API wraps the result in `FetchUsageResponse` (`label`, `rateWindow`).
21
+ 6. `formatResetDescription(resetAt)` converts reset timestamps to human-readable relative strings (`5m`, `2h30m`, `1d`, etc.).
22
+
23
+ ## Integration Points
24
+
25
+ - **Local auth config**: `PathUtil.findPiAuthConfig()` supplies `SubscriptionAuthconfig`, which maps providers to `{ access, accountId }` objects.
26
+ - **Anthropic API**: `AnthropicOauthUsageStrategy` calls `https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer <token>` and `anthropic-beta: oauth-2025-04-20`.
27
+ - **OpenAI Codex API**: `OpenAiCodexUsageStrategy` calls `https://chatgpt.com/backend-api/wham/usage` with bearer token and optional `ChatGPT-Account-Id` header.
28
+ - **Shared utilities**: depends on `src/utils/path.util` for config discovery and `src/utils/raw-data-parser.util` for defensive JSON parsing.
@@ -0,0 +1,44 @@
1
+ # subscription-usage strategy codemap
2
+
3
+ ## Responsibility
4
+
5
+ This directory contains provider-specific implementations of `SubscriptionUsageStrategy`.
6
+ Each strategy is responsible for:
7
+
8
+ - Building the authenticated HTTP request for a single provider.
9
+ - Fetching usage data from that provider's API.
10
+ - Parsing the raw JSON response into a normalized `RateWindow[]`.
11
+ - Returning `undefined` when no usable usage data is available.
12
+
13
+ Current strategies:
14
+
15
+ - `AnthropicOauthUsageStrategy` — fetches Anthropic OAuth usage and exposes the `five_hour` and `seven_day` windows.
16
+ - `OpenAiCodexUsageStrategy` — fetches OpenAI Codex usage and exposes the main `rate_limit` windows plus any `additional_rate_limits`.
17
+
18
+ ## Design Patterns
19
+
20
+ - **Strategy pattern** — both classes implement the common `SubscriptionUsageStrategy` interface, so the caller (`SubscriptionUsageApi`) can treat them uniformly.
21
+ - **Encapsulation of provider details** — request construction, response parsing, and window labeling are kept private to each strategy.
22
+ - **Defensive parsing** — unknown API responses are coerced safely through `RawDataParser` before being used.
23
+ - **Helper decomposition** — the OpenAI strategy splits parsing into small private helpers (`pushRateLimitWindows`, `pushRateWindow`, `getResetDate`, `getWindowLabel`) to keep the main flow readable.
24
+
25
+ ## Data & Control Flow
26
+
27
+ 1. `SubscriptionUsageApi.fetchUsage(strategy)` loads provider credentials and calls `strategy.fetchUsage(auth)`.
28
+ 2. Each strategy builds a provider-specific `Request` and calls `fetch`.
29
+ 3. The JSON response is converted to a `Record<string, unknown>` via `RawDataParser.asRecord`.
30
+ 4. Relevant fields are extracted:
31
+ - **Anthropic**: iterates over `five_hour` and `seven_day`, reads `utilization` as `usedPercent`, and parses `resets_at` into a `Date`.
32
+ - **OpenAI**: reads `rate_limit.primary_window` and `rate_limit.secondary_window`, then loops over `additional_rate_limits`.
33
+ 5. Each discovered window is pushed as a `RateWindow` object with `label`, `usedPercent`, and optional `resetAt`.
34
+ 6. The array is returned to `SubscriptionUsageApi`, which wraps it in a `FetchUsageResponse` keyed by the strategy's `label`.
35
+
36
+ ## Integration Points
37
+
38
+ - **Interface contract**: strategies implement `SubscriptionUsageStrategy` and use the `ProviderAuth`, `RateWindow`, and `SubscriptionProvider` types from `../subscription-usage-api.util`.
39
+ - **Shared utility**: all parsing helpers come from `../../../utils/raw-data-parser.util` (`RawDataParser`).
40
+ - **Network layer**: strategies use the global `fetch` API directly.
41
+ - **Provider endpoints**:
42
+ - Anthropic: `https://api.anthropic.com/api/oauth/usage` — authenticated with a `Bearer` token and the `anthropic-beta: oauth-2025-04-20` header.
43
+ - OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage` — authenticated with a `Bearer` token and an optional `ChatGPT-Account-Id` header.
44
+ - **Consumer**: `SubscriptionUsageApi` orchestrates strategies, loads auth config via `PathUtil.findPiAuthConfig`, and formats results for downstream display.
@@ -0,0 +1,29 @@
1
+ # src/schemas/
2
+
3
+ ## Responsibility
4
+
5
+ This directory defines the Zod-based configuration schemas for the pi-qol plugin. It is the single source of truth for the shape, defaults, and validation rules of user-facing configuration. It covers the top-level config object and per-feature schemas for `auto-session-name`, `model-select`, and `custom-footer`, plus reusable shared schema building blocks.
6
+
7
+ ## Design Patterns
8
+
9
+ - **Schema-first configuration**: Uses Zod to declare schemas and derive TypeScript types via `z.infer`.
10
+ - **Composition over monolith**: Per-feature schemas (`auto-session-name.config.schema.ts`, `model-select.config.schema.ts`, `custom-footer-config.schema.ts`) are composed into the top-level `ConfigSchema` in `config.schema.ts`.
11
+ - **Defaults embedded in schemas**: `.default()` is used heavily so schemas are self-describing and can parse incomplete inputs safely.
12
+ - **Full vs. Partial schemas**: Each feature exposes a full schema and a `Partial*` variant to support partial/merging updates (e.g., user overrides layered over defaults).
13
+ - **Shared primitives**: `shared-config.schema.ts` centralizes reusable concepts like `ModelConfigSchema` and `ColorHexSchema`.
14
+ - **Default object parsing**: `custom-footer-config.schema.ts` uses `Schema.parse({})` to derive concrete default objects rather than hand-writing inline literals.
15
+
16
+ ## Data & Control Flow
17
+
18
+ 1. **Input**: Raw JSON/user config enters the system.
19
+ 2. **Validation**: The top-level `ConfigSchema.parse()` validates the whole object, falling back to per-field defaults for missing keys.
20
+ 3. **Feature decomposition**: `ConfigSchema` delegates validation of each feature section to its dedicated schema (`AutoSessionNameConfigSchema`, `ModelSelectConfigSchema`, `CustomFooterConfigSchema`), which in turn use shared schemas (`ModelConfigSchema`, `ColorHexSchema`, `ReasoningLevelSchema`).
21
+ 4. **Partial updates**: `PartialConfigSchema` and its feature-level partial variants allow validating incremental config changes without requiring a complete config object.
22
+ 5. **Output**: Validated `Config` and `PartialConfig` types are exported for use by config loading, merging, and runtime feature code elsewhere in the plugin.
23
+
24
+ ## Integration Points
25
+
26
+ - **Config loading / merging layer**: `config.schema.ts` exports `Config` and `PartialConfig` types consumed by whatever loads and merges user config with defaults.
27
+ - **Feature modules**: Auto-session-name, model-select, and custom-footer runtime code import their respective config types from this directory.
28
+ - **Constants**: `shared-config.schema.ts` imports `COLOR_HEX_REGEX` from `../constants` to validate hex colors.
29
+ - **Type system**: All schemas feed TypeScript types via `z.infer`, providing compile-time guarantees across the plugin.
@@ -0,0 +1,58 @@
1
+ # src/utils/
2
+
3
+ ## Responsibility
4
+
5
+ This folder contains low-level, reusable helpers used across the `pi-qol` extension. They are pure utility modules with no business logic or UI concerns.
6
+
7
+ - **`crayon.util.ts`** — ANSI terminal styling: colorize text with hex foreground/background colors, apply reverse video, and strip ANSI escape sequences.
8
+ - **`model-resolver.util.ts`** — Resolve a concrete AI model and its authentication credentials from a configured `ModelConfig` or the active session model.
9
+ - **`raw-data-parser.util.ts`** — Type-safe coercion helpers for `unknown` runtime values into `Record<string, unknown>`, trimmed `string`, or finite `number`.
10
+ - **`path.util.ts`** — Locate extension-specific files on disk: generic file existence checks, global/project extension `config.json`, and the shared `auth.json` file.
11
+
12
+ ## Design Patterns
13
+
14
+ - **Static utility classes** (`RawDataParser`, `PathUtil`) group related stateless functions under a clear namespace.
15
+ - **Service class with injected context** (`ModelResolver`) receives an `ExtensionContext` dependency rather than importing global state, keeping it testable and decoupled.
16
+ - **Object-as-namespace export** (`crayon`) exposes a small, discoverable API surface while hiding internal helpers (`hexToRgb`, `fgAnsi`, `bgAnsi`, `ANSI_ESCAPE_REGEX`).
17
+ - **Result/Option types** (`ResolveModelResult`, `FileSearchResult`) avoid exceptions and force callers to handle success and failure paths explicitly.
18
+ - **Function overloads** (`PathUtil.findExtensionConfig`) provide type-safe, domain-driven entry points for global vs. project lookups.
19
+ - **Defensive validation** (`RawDataParser`, `crayon.colorize`) silently returns `undefined` or the original input when data is invalid instead of throwing.
20
+
21
+ ## Data & Control Flow
22
+
23
+ 1. **Terminal styling**
24
+ - `crayon.colorize(text, { fg?, bg? })` validates hex strings against `COLOR_HEX_REGEX`.
25
+ - Valid colors are converted to RGB and wrapped in ANSI escape codes; the text is returned unchanged if no valid colors are provided.
26
+ - `crayon.stripAnsi(text)` removes all ANSI escape sequences using a compiled regex.
27
+
28
+ 2. **Model resolution**
29
+ - `ModelResolver.resolveModel(configModel?)` builds a candidate list in priority order: configured model first, then the active session model (deduplicated).
30
+ - For each candidate it calls `ctx.modelRegistry.getApiKeyAndHeaders(model)`.
31
+ - The first candidate with successful auth is returned as a `ResolvedModel`, including the API key, headers, reasoning settings, and OAuth flag.
32
+ - If all candidates fail, a consolidated error string is returned.
33
+
34
+ 3. **Raw data parsing**
35
+ - `RawDataParser.asRecord` checks for non-null, non-array objects.
36
+ - `stringValue` returns only non-empty trimmed strings.
37
+ - `numberValue` coerces strings/numbers and returns only finite values.
38
+
39
+ 4. **Path resolution**
40
+ - `PathUtil.findFile` checks `existsSync` and returns both existence and the resolved path.
41
+ - `findExtensionConfig({ type: 'global' })` resolves to `<agentDir>/extensions/<EXTENSION_ID>/config.json`.
42
+ - `findExtensionConfig({ type: 'project', cwd })` resolves to `<cwd>/.pi/extensions/<EXTENSION_ID>/config.json`.
43
+ - `findPiAuthConfig` resolves to `<agentDir>/auth.json`.
44
+
45
+ ## Integration Points
46
+
47
+ - `@earendil-works/pi-coding-agent`
48
+ - `ExtensionContext` is injected into `ModelResolver` for model registry and session access.
49
+ - `getAgentDir()` is used by `PathUtil` to resolve global and auth paths.
50
+ - `@earendil-works/pi-ai`
51
+ - `Model<Api>` is the resolved entity returned by `ModelResolver`.
52
+ - `../constants`
53
+ - `COLOR_HEX_REGEX` drives hex validation in `crayon`.
54
+ - `EXTENSION_ID` is used by `PathUtil` to build config directory paths.
55
+ - `../schemas/shared-config.schema`
56
+ - `ModelConfig` and its `reasoning` field are consumed by `ModelResolver`.
57
+ - `node:fs` / `node:path`
58
+ - `PathUtil` relies on Node built-ins for synchronous file existence and cross-platform path joining.
@@ -1,8 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { homedir, userInfo } from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
5
  import { EXTENSION_ID } from '../constants';
5
6
 
7
+ const SHELL_VAR_REGEX = /\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
8
+
6
9
  export type FileSearchResult = {
7
10
  exists: boolean;
8
11
  path: string;
@@ -11,6 +14,27 @@ export type FileSearchResult = {
11
14
  type FindConfigInput = { type: 'global' } | { type: 'project'; cwd: string };
12
15
 
13
16
  export class PathUtil {
17
+ /**
18
+ * Expand shell-style variables in a path string.
19
+ * Supports: `~`, `$HOME`, `$USER`, `$VAR`, `${VAR}`.
20
+ */
21
+ static expandPath(rawPath: string): string {
22
+ let result = rawPath;
23
+
24
+ result = result.replace(SHELL_VAR_REGEX, (_match, braced, bare) => {
25
+ const name = braced ?? bare;
26
+ if (name === 'HOME') return homedir();
27
+ if (name === 'USER') return userInfo().username;
28
+ return process.env[name] ?? _match;
29
+ });
30
+
31
+ if (result === '~' || result.startsWith('~/') || result.startsWith('~\\')) {
32
+ result = path.join(homedir(), result.slice(1));
33
+ }
34
+
35
+ return result;
36
+ }
37
+
14
38
  static findFile(filePath: string): FileSearchResult {
15
39
  if (existsSync(filePath)) {
16
40
  return { exists: true, path: filePath };