pi-bar 0.3.41 → 0.3.44

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  **Never accidentally run Opus on a typo again.** pi-bar keeps your model, thinking level, context pressure, a live progress update, and any extension statuses visible in pi's footer.
7
7
 
8
8
  ```text
9
- claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Reviewing package structure ❯ plan:active ❯ queue:2
9
+ claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Reviewing package structure ❯ Plan active ❯ Queue: 2
10
10
  ```
11
11
 
12
12
  ![pi-bar with low context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.38/assets/screenshot-green.png)
@@ -35,6 +35,20 @@ If pi is already running after install, reload resources:
35
35
  /reload
36
36
  ```
37
37
 
38
+ ## Automatic layout
39
+
40
+ The footer adapts to terminal width without extra configuration:
41
+
42
+ - **Model, thinking level, and context percentage take priority.**
43
+ - Separators have two spaces on each side by default, for a roomier display.
44
+ - Progress text shrinks first. If space is still tight, separator padding reduces to one space per side before compacting content. CWD then compacts from its preferred path to `parent/project`, then `project`; context window size is omitted when needed. An enabled provider prefix can yield before the model name is clipped.
45
+ - Optional token totals, cost, and cache-hit ratio yield whole under width pressure, before overflowing extension badges. Numeric metrics are never shown partially.
46
+ - Extension badges stay in their published order. Badges that cannot fit are hidden whole, with `+N` showing how many are behind the overflow. Open `/bar status` to inspect their text and visibility by key.
47
+ - On very narrow terminals, progress and CWD yield to core information. If even the core cannot fit, the model name is truncated first. At extreme widths, not every value or overflow count can remain visible.
48
+ - Widening the terminal restores the full display. Thinking and context retain their semantic colors throughout.
49
+
50
+ Existing visibility settings still apply. CWD, usage metrics, and the provider prefix are opt-in; the default footer is unchanged.
51
+
38
52
  ## Customization
39
53
 
40
54
  pi-bar works out of the box. Run `/bar` inside pi to choose which footer segments and extension statuses are shown:
@@ -43,7 +57,7 @@ pi-bar works out of the box. Run `/bar` inside pi to choose which footer segment
43
57
  /bar
44
58
  ```
45
59
 
46
- Toggle `Model`, `Thinking level`, `Context usage`, `Current directory`, `Progress update`, and `Extension statuses` between `shown` and `hidden`. If other extensions have published status badges, `/bar` also shows fine-grained `Status: <key>` rows plus a `New extension statuses` default. You can also use commands:
60
+ Toggle `Model`, `Thinking level`, `Context usage`, `Cache hit ratio`, `Estimated session cost`, `Session token totals`, `Current directory`, `Progress update`, and `Extension statuses` between `shown` and `hidden`. `Show provider` controls the optional prefix inside the model segment. `Progress model` opens a searchable model picker. If other extensions have published status badges, `/bar` also shows fine-grained `Status: <key>` rows plus a `New extension statuses` default. You can also use commands:
47
61
 
48
62
  ```text
49
63
  /bar segments list
@@ -52,7 +66,9 @@ Toggle `Model`, `Thinking level`, `Context usage`, `Current directory`, `Progres
52
66
  /bar segments show thinking
53
67
  ```
54
68
 
55
- Allowed segments are `model`, `thinking`, `context`, `cwd`, `progress`, and `extensions`. The `cwd` segment is off by default. The `progress` segment stays hidden until pi-bar has a current update. The `extensions` segment stays hidden when no extension has set a status.
69
+ Allowed segments are `model`, `thinking`, `context`, `cache_hit_ratio`, `cost`, `tokens`, `cwd`, `progress`, and `extensions`. The `cwd`, `cache_hit_ratio`, `cost`, and `tokens` segments are off by default. The `progress` segment stays hidden until pi-bar has a current update. The `extensions` segment stays hidden when no extension has set a status.
70
+
71
+ Tab completes `/bar` subcommands, actions, segment names, known status keys, and configured progress-model IDs. Completion preserves preceding arguments, including comma-separated lists such as `/bar segments show model,ca`.
56
72
 
57
73
  You can also set startup defaults with environment variables before launching pi:
58
74
 
@@ -85,21 +101,83 @@ The cap must be an integer of at least 8; invalid values use 36. Very long direc
85
101
 
86
102
  This is Pi's session working directory (`ctx.cwd`), not a live shell directory: `cd` inside a tool command does not change it. Visibility persists through the existing `/bar` configuration.
87
103
 
104
+ ### Show the model provider
105
+
106
+ Provider prefixes are hidden by default. Enable `Show provider` in `/bar`, or use:
107
+
108
+ ```text
109
+ /bar provider show
110
+ /bar provider hide
111
+ /bar provider
112
+ ```
113
+
114
+ The last command reports the current setting. The choice persists as `showProvider` in `~/.pi/agent/pi-bar.json`; an absent setting means `false`.
115
+
116
+ ```text
117
+ claude-opus-4.7 # default
118
+ anthropic/claude-opus-4.7 # direct provider, enabled
119
+ openrouter/claude-opus-4.7 # routed provider, enabled
120
+ ```
121
+
122
+ The prefix uses the actual model provider, not a namespace embedded in the model ID. It stays inside the model segment: hiding `model` hides both. Under width pressure, the prefix yields before clipping the model name and returns when space is available.
123
+
124
+ ### Show cache, cost, and token metrics
125
+
126
+ Enable any of the optional metrics through `/bar` or the existing segment commands:
127
+
128
+ ```text
129
+ /bar segments show cache_hit_ratio cost tokens
130
+ ```
131
+
132
+ ```text
133
+ claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ CH:84% ❯ ≈$0.123 ❯ ↑12k ↓3k
134
+ ```
135
+
136
+ | Segment | Meaning |
137
+ | --- | --- |
138
+ | `cache_hit_ratio` | Latest active-branch assistant prompt's cache-read percentage: `cacheRead / (input + cacheRead + cacheWrite)`. Hidden when that response has no reported prompt usage. Restored on resume and `/tree`; tool, summary, and progress calls do not replace this value. |
139
+ | `cost` | Estimated dollar cost for recorded usage in the current session file. `≈` means an estimate, **not an invoice**. |
140
+ | `tokens` | Cumulative input (`↑`, including cache reads and writes) and output (`↓`, including reported reasoning tokens). These are usage totals, not current context size or tokens/sec. |
141
+
142
+ Cost and token totals include assistant responses, usage reported by tools, compaction/branch summaries, and pi-bar progress calls. They include all branches in the current session file, so `/tree` does not erase prior spend; `/new` starts fresh. Both stay hidden until usage is available. A zero-cost response with token usage still shows `≈$0.000`.
143
+
144
+ Progress usage is saved as numeric-only `pi-bar-progress-usage` custom entries, without prompts or response text. These entries survive reloads but are not sent to the main model. Older sessions may lack progress-call usage. Requests with no reported usage, including some failed/interrupted calls, cannot be counted.
145
+
146
+ Prices come from the usage estimates Pi/providers report. Subscription allowances, invoices, and missing/custom model pricing can differ; a reported zero is not a billing guarantee. Metrics make no additional model requests. Usage is aggregated at lifecycle events, not by scanning history on every footer render.
147
+
88
148
  ### Configure live progress updates
89
149
 
90
150
  pi-bar shows a short, plain-English description of what pi is working on right now. It refreshes as pi works and resets when you switch branches in the session tree, so stale updates never follow you across tasks. Hide `Progress update` in `/bar`, run `/bar segments hide progress`, or set `PI_BAR_SHOW` without `progress` to disable it.
91
151
 
92
- Pick a specific model for the update by setting the env var or pi settings:
152
+ **Pick a model interactively:** open `/bar`, select `Progress model` directly below `Progress update`, and press Enter. Type a provider or model ID (for example `gpt`), then press Enter to select. Esc cancels without changing the preference. The picker shows models with configured Pi credentials and an `Auto` option; it does not validate keys or make model requests.
153
+
154
+ **Or use commands with Tab completion:**
155
+
156
+ ```text
157
+ /bar progress-model
158
+ /bar progress-model openai/gpt-4.1-mini
159
+ /bar progress-model auto
160
+ ```
161
+
162
+ The first command reports the effective preference. After `/bar progress-model `, Tab offers `auto` and models with configured Pi credentials. Search by provider or model fragment, such as `gpt` or `haiku`; completion inserts the full `provider/model` ID, including any nested namespace. Suggestions use the current local model catalog, without refreshing catalogs, resolving credentials, or making model requests. Unknown/unavailable model arguments leave the preference unchanged.
163
+
164
+ The choice saves as `progressModel` in `~/.pi/agent/pi-bar.json` (or `PI_BAR_CONFIG`) for all projects. It applies to subsequent progress updates without `/reload`, cancels pending old-model updates, and leaves your main chat model and credentials unchanged. Choosing a model does not enable a hidden progress segment.
165
+
166
+ `Auto` tries fast Codex models, then Anthropic Haiku. To use an OpenAI API key instead of a Codex subscription, choose an **`openai/...`** model explicitly, such as `openai/gpt-4.1-mini`. Progress uses that provider's normal Pi credentials; no separate pi-bar key is needed. Configured but revoked credentials can still fail.
167
+
168
+ You can also set a model before launching Pi:
93
169
 
94
170
  ```bash
95
- PI_BAR_PROGRESS_MODEL=anthropic/claude-haiku-4-5 pi
171
+ PI_BAR_PROGRESS_MODEL=openai/gpt-4.1-mini pi
96
172
  ```
97
173
 
98
- Or in pi settings: `bar.progressModel`. Otherwise pi-bar picks a fast model you are already authenticated with.
174
+ Preference order: `PI_BAR_PROGRESS_MODEL` → saved pi-bar `progressModel` → project Pi settings → global Pi settings → Auto. Within each Pi settings scope, `bar.progressModel` takes precedence over the legacy `progress.model` setting. A nonempty environment override makes both the picker and command read-only and suppresses model-value completion; unset it and restart Pi to choose another model. A saved `"auto"` explicitly enables automatic selection rather than falling back to Pi settings. Remove `progressModel` from the pi-bar config to use Pi settings again.
99
175
 
100
176
  ### Configure extension statuses
101
177
 
102
- Other pi extensions can publish small status badges. Pi-bar collects them into the `extensions` segment, strips embedded terminal colors, and separates each badge with the same `❯` divider used by other footer segments. Run `/bar` or `/bar status` inside pi to pick which ones to show:
178
+ Other pi extensions can publish small status badges. Pi-bar displays their text as written, without prepending internal keys: `setStatus("mcp", "MCP: 2/2 servers")` appears as `MCP: 2/2 servers`, not `mcp:MCP: 2/2 servers`. Extensions should publish self-describing text; pi-bar does not guess labels for bare values.
179
+
180
+ Pi-bar strips embedded terminal colors and control sequences, and separates each badge with the same `❯` divider used by other footer segments. Keys remain available for filtering and identification in `/bar`; selecting a status row shows its current sanitized text. Run `/bar` or `/bar status` inside pi to inspect statuses or pick which ones to show:
103
181
 
104
182
  ```text
105
183
  /bar status
@@ -130,7 +208,7 @@ npm test
130
208
  npm run check
131
209
  ```
132
210
 
133
- Tests cover path formatting, terminal safety, width limits, and segment visibility/persistence. Test configuration mirrors Pi's `pi-ai/compat` loader alias; runtime dependencies remain optional peers supplied by Pi.
211
+ Tests cover path formatting, display-ready statuses, responsive layout, all 512 segment visibility combinations, terminal safety, ANSI/Unicode widths, provider settings, progress-model selection, command completion, usage accounting, progress-call persistence, session/tree lifecycle, and config persistence. Test configuration mirrors Pi's `pi-ai/compat` loader alias; runtime dependencies remain optional peers supplied by Pi.
134
212
 
135
213
  ## Security note
136
214
 
@@ -2,7 +2,7 @@
2
2
  * pi-bar — footer / statusline extension.
3
3
  *
4
4
  * Replaces pi's built-in footer with left-aligned segments:
5
- * <model> ❯ think:<level> ❯ <context% / window> ❯ [cwd] ❯ <progress> ❯ <extensions>
5
+ * <model> ❯ think:<level> ❯ <context% / window> ❯ [metrics] ❯ [cwd] ❯ <progress> ❯ <extensions>
6
6
  *
7
7
  * Example:
8
8
  * claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Reviewing package structure
@@ -21,7 +21,7 @@
21
21
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { dirname, join, posix, win32 } from "node:path";
24
- import { complete, type UserMessage } from "@earendil-works/pi-ai";
24
+ import { complete, type Usage, type UserMessage } from "@earendil-works/pi-ai";
25
25
  import {
26
26
  getSettingsListTheme,
27
27
  SettingsManager,
@@ -31,13 +31,15 @@ import {
31
31
  } from "@earendil-works/pi-coding-agent";
32
32
  import {
33
33
  Container,
34
+ type AutocompleteItem,
34
35
  type SettingItem,
35
36
  SettingsList,
36
37
  truncateToWidth,
37
38
  visibleWidth,
38
39
  } from "@earendil-works/pi-tui";
40
+ import { FooterUsageTracker, hasUsage, PROGRESS_USAGE_ENTRY_TYPE, totalsFromUsage } from "./usage.js";
39
41
 
40
- type SegmentName = "model" | "thinking" | "context" | "cwd" | "progress" | "extensions";
42
+ type SegmentName = "model" | "thinking" | "context" | "cache_hit_ratio" | "cost" | "tokens" | "cwd" | "progress" | "extensions";
41
43
  type StatusFilter =
42
44
  | { mode: "all"; hidden: Set<string> }
43
45
  | { mode: "only"; shown: Set<string> };
@@ -47,6 +49,8 @@ type SerializedStatusFilter =
47
49
  type GlobalBarConfig = {
48
50
  statusFilter?: SerializedStatusFilter;
49
51
  segments?: SegmentName[];
52
+ showProvider?: boolean;
53
+ progressModel?: string;
50
54
  };
51
55
  type ProgressActivityType =
52
56
  | "user_message"
@@ -120,6 +124,9 @@ const ALL_SEGMENTS: readonly SegmentName[] = [
120
124
  "model",
121
125
  "thinking",
122
126
  "context",
127
+ "cache_hit_ratio",
128
+ "cost",
129
+ "tokens",
123
130
  "cwd",
124
131
  "progress",
125
132
  "extensions",
@@ -128,6 +135,9 @@ const SEGMENT_LABELS: Record<SegmentName, string> = {
128
135
  model: "Model",
129
136
  thinking: "Thinking level",
130
137
  context: "Context usage",
138
+ cache_hit_ratio: "Cache hit ratio",
139
+ cost: "Estimated session cost",
140
+ tokens: "Session token totals",
131
141
  cwd: "Current directory",
132
142
  progress: "Progress update",
133
143
  extensions: "Extension statuses",
@@ -136,7 +146,6 @@ const DEFAULT_WARNING_THRESHOLD = 70;
136
146
  const DEFAULT_ERROR_THRESHOLD = 90;
137
147
 
138
148
  const SEGMENT_SEPARATOR = "❯";
139
- const EXTENSION_STATUS_SEPARATOR = SEGMENT_SEPARATOR;
140
149
 
141
150
  function formatTokens(n: number): string {
142
151
  if (n >= 1_000_000) {
@@ -150,10 +159,17 @@ function formatTokens(n: number): string {
150
159
  return `${n}`;
151
160
  }
152
161
 
153
- function formatModelName(id: string | undefined): string {
162
+ export function formatModelName(
163
+ model: { id: string; provider?: string } | undefined,
164
+ showProvider = false,
165
+ ): string {
166
+ const id = stripTerminalControls(model?.id ?? "");
154
167
  if (!id) return "no-model";
155
- const base = id.includes("/") ? (id.split("/").pop() ?? id) : id;
156
- return base.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
168
+ const base = id.split("/").filter(Boolean).at(-1) ?? id;
169
+ const name = base.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
170
+ // The namespace in a model ID is not necessarily its routing provider.
171
+ const provider = stripTerminalControls(model?.provider ?? "");
172
+ return showProvider && provider ? `${provider}/${name}` : name;
157
173
  }
158
174
 
159
175
  const DEFAULT_CWD_MAX_WIDTH = 36;
@@ -195,6 +211,117 @@ export function formatCwd(
195
211
  return truncateToWidth(compact, maxWidth);
196
212
  }
197
213
 
214
+ /** Preferred directory display, then parent/project, then project only. */
215
+ export function cwdVariants(
216
+ cwd: string,
217
+ home = homedir(),
218
+ maxWidth = DEFAULT_CWD_MAX_WIDTH,
219
+ paths = process.platform === "win32" ? win32 : posix,
220
+ ): string[] {
221
+ const safeCwd = stripTerminalControls(cwd);
222
+ const leaf = paths.basename(safeCwd);
223
+ const parent = paths.basename(paths.dirname(safeCwd));
224
+ return [
225
+ formatCwd(cwd, home, maxWidth, paths),
226
+ ...[parent && leaf ? `${parent}${paths.sep}${leaf}` : leaf, leaf]
227
+ .filter(Boolean)
228
+ .map((text) => truncateToWidth(stripTerminalControls(text), maxWidth, "…")),
229
+ ].map(stripTerminalControls);
230
+ }
231
+
232
+ export type FooterSegment = {
233
+ name: SegmentName;
234
+ text: string;
235
+ /** Shorter, already styled representations, in preference order. */
236
+ alternatives?: readonly string[];
237
+ };
238
+
239
+ /** Pure, column-aware layout. The divider may be styled but has no padding. Never split a status badge. */
240
+ export function layoutFooter(
241
+ segments: readonly FooterSegment[],
242
+ badges: readonly string[],
243
+ width: number,
244
+ divider = SEGMENT_SEPARATOR,
245
+ ): string {
246
+ if (!Number.isFinite(width) || width <= 0) return "";
247
+ width = Math.floor(width);
248
+ let separator = ` ${divider} `;
249
+ const items = segments.map((segment) => ({ ...segment }));
250
+ const extensions = items.find((item) => item.name === "extensions");
251
+ let shownBadges = badges.length;
252
+ const updateBadges = () => {
253
+ if (!extensions) return;
254
+ const hidden = badges.length - shownBadges;
255
+ extensions.text = [...badges.slice(0, shownBadges), ...(hidden ? [`+${hidden}`] : [])].join(separator);
256
+ };
257
+ updateBadges();
258
+ const line = () => items.map((item) => item.text).filter(Boolean).join(separator);
259
+ const overflow = () => visibleWidth(line()) - width;
260
+ const progress = items.find((item) => item.name === "progress");
261
+ if (progress && overflow() > 0) {
262
+ const currentWidth = visibleWidth(progress.text);
263
+ // Keep a short activity fragment while trying the other compact forms.
264
+ const minimumWidth = Math.min(12, width);
265
+ const progressWidth = Math.min(currentWidth, Math.max(minimumWidth, currentWidth - overflow()));
266
+ progress.text = truncateToWidth(progress.text, progressWidth, "…");
267
+ }
268
+ // Keep the roomy default while progress alone can absorb width pressure.
269
+ // Tighten gaps before compacting paths/context or overflowing whole badges.
270
+ if (overflow() > 0) {
271
+ separator = ` ${divider} `;
272
+ updateBadges();
273
+ }
274
+ for (const name of ["cwd", "context", "thinking", "model"] as const) {
275
+ const item = items.find((item) => item.name === name);
276
+ if (!item) continue;
277
+ for (const alternative of item.alternatives ?? []) {
278
+ if (overflow() <= 0) break;
279
+ if (visibleWidth(alternative) < visibleWidth(item.text)) item.text = alternative;
280
+ }
281
+ }
282
+ // Optional metrics yield whole, never leaving a misleading partial number.
283
+ for (const name of ["tokens", "cost", "cache_hit_ratio"] as const) {
284
+ if (overflow() <= 0) break;
285
+ const item = items.find((item) => item.name === name);
286
+ if (item) item.text = "";
287
+ }
288
+ while (extensions && shownBadges > 0 && overflow() > 0) {
289
+ shownBadges--;
290
+ updateBadges();
291
+ }
292
+ // On very narrow terminals, remove optional content before clipping identity.
293
+ for (const name of ["progress", "cwd", "extensions"] as const) {
294
+ if (overflow() <= 0) break;
295
+ const item = items.find((item) => item.name === name);
296
+ if (item) {
297
+ item.text = name === "cwd"
298
+ ? truncateToWidth(item.text, Math.max(0, visibleWidth(item.text) - overflow()), "…")
299
+ : "";
300
+ }
301
+ }
302
+ const model = items.find((item) => item.name === "model");
303
+ if (model && overflow() > 0) {
304
+ model.text = truncateToWidth(model.text, Math.max(0, visibleWidth(model.text) - overflow()), "…");
305
+ }
306
+ // Whole-badge overflow can free more space than needed. Use that space rather
307
+ // than leaving unnecessarily compact context, paths, or progress on screen.
308
+ for (const name of ["model", "context", "thinking", "cwd", "progress"] as const) {
309
+ const item = items.find((item) => item.name === name);
310
+ const original = segments.find((segment) => segment.name === name);
311
+ if (!item?.text || !original) continue;
312
+ const budget = visibleWidth(item.text) - overflow();
313
+ if (name === "progress") {
314
+ item.text = truncateToWidth(original.text, Math.max(0, budget), "…");
315
+ } else {
316
+ const preferred = [original.text, ...(original.alternatives ?? [])]
317
+ .find((text) => visibleWidth(text) <= budget);
318
+ if (preferred !== undefined) item.text = preferred;
319
+ }
320
+ }
321
+ // A terminal narrower than thinking + context cannot show every core value.
322
+ return truncateToWidth(line(), width, "…");
323
+ }
324
+
198
325
  function thinkingColor(level: string): ThemeColor {
199
326
  switch (level) {
200
327
  case "off":
@@ -276,7 +403,7 @@ function parseThresholds(): { warningThreshold: number; errorThreshold: number }
276
403
 
277
404
  function parseProgressModelSpec(value: string): ProgressModelPreference | undefined {
278
405
  const trimmed = value.trim();
279
- if (!trimmed || trimmed === "auto") return undefined;
406
+ if (!trimmed || trimmed === "auto" || /[\s\x00-\x1f\x7f-\x9f]/u.test(trimmed)) return undefined;
280
407
  const separator = trimmed.indexOf("/");
281
408
  if (separator <= 0 || separator === trimmed.length - 1) return undefined;
282
409
  return { provider: trimmed.slice(0, separator), id: trimmed.slice(separator + 1) };
@@ -298,10 +425,14 @@ function settingsModelValue(settings: Record<string, unknown>): string | undefin
298
425
  return undefined;
299
426
  }
300
427
 
301
- function resolveProgressModelPreference(cwd: string): ProgressModelPreference | undefined {
428
+ export function resolveProgressModelPreference(cwd: string): ProgressModelPreference | undefined {
302
429
  const envModel = process.env.PI_BAR_PROGRESS_MODEL;
303
430
  if (envModel) return parseProgressModelSpec(envModel);
304
431
 
432
+ // An explicit /bar choice wins over legacy Pi settings, including "auto".
433
+ const savedModel = readGlobalConfig().progressModel;
434
+ if (savedModel !== undefined) return parseProgressModelSpec(savedModel);
435
+
305
436
  const settings = SettingsManager.create(cwd);
306
437
  const projectModel = settingsModelValue(
307
438
  settings.getProjectSettings() as Record<string, unknown>,
@@ -324,8 +455,64 @@ const FAST_PROGRESS_MODELS: readonly ProgressModelPreference[] = [
324
455
  { provider: "anthropic", id: "claude-haiku-4-5-20251001" },
325
456
  ];
326
457
 
327
- function formatProgressModelKey(model: ProgressModelPreference): string {
328
- return `${model.provider}/${model.id}`;
458
+ function formatProgressModelKey(model: ProgressModelPreference | undefined): string {
459
+ return model ? `${model.provider}/${model.id}` : "auto";
460
+ }
461
+
462
+ function parseSerializedProgressModel(value: unknown): string | undefined {
463
+ if (typeof value !== "string") return undefined;
464
+ if (value.trim() === "auto") return "auto";
465
+ const model = parseProgressModelSpec(value);
466
+ return model ? formatProgressModelKey(model) : undefined;
467
+ }
468
+
469
+ function createProgressModelPicker(
470
+ ctx: ExtensionContext,
471
+ currentValue: string,
472
+ done: (value?: string) => void,
473
+ ): SettingsList {
474
+ // Availability is a local snapshot of configured credentials, not a key
475
+ // validation request. Never resolve or display credentials in this UI.
476
+ const choices = new Map<string, SettingItem>();
477
+ for (const model of ctx.modelRegistry.getAvailable()) {
478
+ const key = formatProgressModelKey(model);
479
+ if (parseSerializedProgressModel(key) !== key) continue;
480
+ choices.set(key, {
481
+ id: key,
482
+ label: key,
483
+ currentValue: key === currentValue ? "selected" : "",
484
+ description: `${key} — ${stripTerminalControls(model.name)}. Uses this provider's Pi credentials.`,
485
+ values: ["selected"],
486
+ });
487
+ }
488
+ const items: SettingItem[] = [{
489
+ id: "auto",
490
+ label: "Auto",
491
+ currentValue: currentValue === "auto" ? "selected" : "",
492
+ description: choices.size > 0
493
+ ? "Try fast Codex models, then Anthropic Haiku. Choose an openai/ model explicitly to use an OpenAI API key."
494
+ : "No models with configured credentials. Use /login, then reopen this picker.",
495
+ values: ["selected"],
496
+ }];
497
+ if (currentValue !== "auto" && !choices.has(currentValue)) {
498
+ items.push({
499
+ id: currentValue,
500
+ label: stripTerminalControls(currentValue),
501
+ currentValue: "unavailable",
502
+ description: "Current preference is not in Pi's available models. Use /login or choose another model.",
503
+ });
504
+ }
505
+ items.push(...[...choices.values()].sort((a, b) => a.id.localeCompare(b.id)));
506
+ const picker = new SettingsList(
507
+ items,
508
+ Math.min(items.length + 2, 14),
509
+ getSettingsListTheme(),
510
+ (id) => done(id),
511
+ () => done(),
512
+ { enableSearch: true },
513
+ );
514
+ picker.selectItem(currentValue);
515
+ return picker;
329
516
  }
330
517
 
331
518
  async function getModelAuth(
@@ -703,12 +890,19 @@ class FooterProgressEngine {
703
890
  private normalCheckpointTimer?: ReturnType<typeof setTimeout>;
704
891
  private normalCheckpointBurstStartedAt?: number;
705
892
 
706
- constructor(private readonly requestRender: () => void) {}
893
+ constructor(
894
+ private readonly requestRender: () => void,
895
+ private readonly createUsageRecorder: (ctx: ExtensionContext) => (usage: Usage) => void,
896
+ ) {}
707
897
 
708
898
  text(): string | null {
709
899
  return this.currentText;
710
900
  }
711
901
 
902
+ modelKey(): string {
903
+ return formatProgressModelKey(this.configuredModel);
904
+ }
905
+
712
906
  startSession(cwd: string): void {
713
907
  this.configuredModel = resolveProgressModelPreference(cwd);
714
908
  this.facts.resetConversation();
@@ -954,6 +1148,7 @@ class FooterProgressEngine {
954
1148
 
955
1149
  abortController = new AbortController();
956
1150
  this.abortController = abortController;
1151
+ const recordUsage = this.createUsageRecorder(ctx);
957
1152
  const response = await complete(
958
1153
  auth.model,
959
1154
  { systemPrompt: checkpointSystemPrompt(job), messages: [prompt] },
@@ -967,6 +1162,9 @@ class FooterProgressEngine {
967
1162
  signal: abortController.signal,
968
1163
  },
969
1164
  );
1165
+ // A superseded/aborted request can still report billed usage. Account for
1166
+ // it even when its text must no longer be displayed.
1167
+ recordUsage(response.usage);
970
1168
  if (!this.isCurrentJob(job) || response.stopReason !== "stop") return;
971
1169
 
972
1170
  const rawText = extractTextContent(response.content) ?? "";
@@ -1566,7 +1764,7 @@ function shouldShowStatus(key: string, filter: StatusFilter): boolean {
1566
1764
  return !filter.hidden.has(key);
1567
1765
  }
1568
1766
 
1569
- function formatExtensionStatuses(
1767
+ export function formatExtensionStatuses(
1570
1768
  statuses: ReadonlyMap<string, string>,
1571
1769
  filter: StatusFilter,
1572
1770
  seenStatusKeys: Set<string>,
@@ -1577,9 +1775,7 @@ function formatExtensionStatuses(
1577
1775
  seenStatusKeys.add(key);
1578
1776
  return shouldShowStatus(key, filter);
1579
1777
  })
1580
- .map(([key, text]) =>
1581
- `${stripTerminalControls(key)}:${stripTerminalControls(text)}`,
1582
- );
1778
+ .map(([, text]) => stripTerminalControls(text));
1583
1779
 
1584
1780
  return parts.length > 0 ? parts : null;
1585
1781
  }
@@ -1652,6 +1848,8 @@ function readGlobalConfig(): GlobalBarConfig {
1652
1848
  return {
1653
1849
  statusFilter: statusFilter ? serializeStatusFilter(statusFilter) : undefined,
1654
1850
  segments: parseSerializedSegments(data.segments) ?? undefined,
1851
+ showProvider: typeof data.showProvider === "boolean" ? data.showProvider : undefined,
1852
+ progressModel: parseSerializedProgressModel(data.progressModel),
1655
1853
  };
1656
1854
  } catch {
1657
1855
  return {};
@@ -1684,6 +1882,68 @@ function writeGlobalSegments(segments: readonly SegmentName[]): void {
1684
1882
  writeGlobalConfig({ ...existing, segments: serializeSegments(segments) });
1685
1883
  }
1686
1884
 
1885
+ /** Pi replaces the entire argument prefix, not just its last word. */
1886
+ export function completeBarArguments(
1887
+ prefix: string,
1888
+ knownStatusKeys: readonly string[] = [],
1889
+ progressModelKeys: readonly string[] = [],
1890
+ ): AutocompleteItem[] | null {
1891
+ const words = prefix.trimStart().split(/\s+/);
1892
+ let fragment = words.at(-1) ?? "";
1893
+ let stem = prefix.slice(0, prefix.length - fragment.length);
1894
+ let candidates: readonly string[];
1895
+ let selected: string[] = [];
1896
+ let segmentValues = false;
1897
+ let modelValues = false;
1898
+ const section = words[0];
1899
+ const segmentSection = section === "segments" || section === "segment" || section === "footer";
1900
+ const statusSection = section === "status" || section === "statuses";
1901
+
1902
+ if (words.length === 1) {
1903
+ candidates = ["config", "segments", "status", "provider", "progress-model", "list"];
1904
+ } else if (words.length === 2 && (segmentSection || statusSection)) {
1905
+ candidates = ["list", "all", "none", "only", "show", "hide", "config"];
1906
+ } else if (words.length === 2 && section === "provider") {
1907
+ candidates = ["show", "hide"];
1908
+ } else if (words.length === 2 && section === "progress-model") {
1909
+ modelValues = true;
1910
+ candidates = ["auto", ...[...new Set(progressModelKeys.filter(
1911
+ (key) => typeof key === "string" && key !== "auto" && parseSerializedProgressModel(key) === key,
1912
+ ))].sort()];
1913
+ } else if ((segmentSection || statusSection) && ["only", "show", "hide"].includes(words[1])) {
1914
+ const comma = fragment.lastIndexOf(",");
1915
+ const priorValues = fragment.slice(0, comma + 1);
1916
+ stem += priorValues;
1917
+ fragment = fragment.slice(comma + 1);
1918
+ selected = [...words.slice(2, -1), priorValues].join(" ").split(/[\s,]+/).filter(Boolean);
1919
+ segmentValues = segmentSection;
1920
+ // Keys containing delimiters/controls are still configurable in the UI,
1921
+ // but cannot be represented safely by the existing command grammar.
1922
+ candidates = segmentSection ? ALL_SEGMENTS : [...new Set(knownStatusKeys)].filter(
1923
+ (key) => typeof key === "string" && key.length > 0 && !/[\s,\x00-\x1f\x7f-\x9f]/u.test(key),
1924
+ ).sort();
1925
+ } else {
1926
+ return null;
1927
+ }
1928
+
1929
+ const normalize = (value: string) => segmentValues || modelValues ? value.toLowerCase() : value;
1930
+ const used = new Set(selected.map(normalize));
1931
+ const query = normalize(fragment);
1932
+ const matches = candidates
1933
+ .filter((value) => {
1934
+ const normalized = normalize(value);
1935
+ return (modelValues ? normalized.includes(query) : normalized.startsWith(query)) && !used.has(normalized);
1936
+ })
1937
+ .map((value) => ({
1938
+ value: `${stem}${value}`,
1939
+ label: value,
1940
+ description: segmentValues && isSegmentName(value) ? SEGMENT_LABELS[value]
1941
+ : modelValues ? value === "auto" ? "Automatic fast-model selection" : "Progress model"
1942
+ : undefined,
1943
+ }));
1944
+ return matches.length > 0 ? matches : null;
1945
+ }
1946
+
1687
1947
  function getKnownStatusKeys(filter: StatusFilter, seenStatusKeys: Set<string>): string[] {
1688
1948
  const keys = new Set(seenStatusKeys);
1689
1949
  if (filter.mode === "only") {
@@ -1698,9 +1958,36 @@ export default function (pi: ExtensionAPI) {
1698
1958
  let requestRender: (() => void) | undefined;
1699
1959
  let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
1700
1960
  let visibleSegments: SegmentName[] = readGlobalSegments() ?? DEFAULT_SEGMENTS;
1961
+ let showProvider = readGlobalConfig().showProvider ?? false;
1701
1962
  const seenStatusKeys = new Set<string>();
1963
+ let currentStatuses: ReadonlyMap<string, string> = new Map();
1964
+ let completionModelRegistry: ExtensionContext["modelRegistry"] | undefined;
1965
+ const statusDescription = (key: string) =>
1966
+ stripTerminalControls(currentStatuses.get(key) ?? "") || "No current status text";
1702
1967
  const refresh = () => requestRender?.();
1703
- const progress = new FooterProgressEngine(refresh);
1968
+ const usageTracker = new FooterUsageTracker();
1969
+ const syncUsage = (ctx: ExtensionContext, restoreBranch = false) => {
1970
+ usageTracker.syncEntries(ctx.sessionManager.getEntries());
1971
+ if (restoreBranch) usageTracker.restoreBranch(ctx.sessionManager.getBranch());
1972
+ };
1973
+ let sessionEpoch = 0;
1974
+ let sessionOpen = false;
1975
+ const progress = new FooterProgressEngine(refresh, (ctx) => {
1976
+ const epoch = sessionEpoch;
1977
+ return (usage) => {
1978
+ // Never append a late response into a replacement/reloaded session.
1979
+ if (!sessionOpen || epoch !== sessionEpoch) return;
1980
+ const totals = totalsFromUsage(usage);
1981
+ if (!hasUsage(totals)) return;
1982
+ try {
1983
+ pi.appendEntry(PROGRESS_USAGE_ENTRY_TYPE, totals);
1984
+ syncUsage(ctx);
1985
+ refresh();
1986
+ } catch {
1987
+ // Usage persistence is best effort; do not discard a valid progress update.
1988
+ }
1989
+ };
1990
+ });
1704
1991
  const restoreStatusFilter = (ctx: ExtensionContext) => {
1705
1992
  let restoredFilter = readGlobalStatusFilter();
1706
1993
  if (!restoredFilter) {
@@ -1716,6 +2003,29 @@ export default function (pi: ExtensionAPI) {
1716
2003
  writeGlobalStatusFilter(statusFilter);
1717
2004
  refresh();
1718
2005
  };
2006
+ const setShowProvider = (value: boolean) => {
2007
+ showProvider = value;
2008
+ writeGlobalConfig({ ...readGlobalConfig(), showProvider });
2009
+ refresh();
2010
+ };
2011
+ const setProgressModel = (value: string, ctx: ExtensionContext): boolean => {
2012
+ if (process.env.PI_BAR_PROGRESS_MODEL) {
2013
+ ctx.ui.notify("Progress model is controlled by PI_BAR_PROGRESS_MODEL. Unset it and restart Pi to choose here.", "warning");
2014
+ return false;
2015
+ }
2016
+ const model = parseSerializedProgressModel(value);
2017
+ if (!model) return false;
2018
+ try {
2019
+ writeGlobalConfig({ ...readGlobalConfig(), progressModel: model });
2020
+ } catch {
2021
+ ctx.ui.notify("Could not save the progress model. Check pi-bar config file permissions; selection unchanged.", "error");
2022
+ return false;
2023
+ }
2024
+ // Reset/cancel old-provider jobs, but do not enable a hidden segment.
2025
+ if (visibleSegments.includes("progress") && model !== progress.modelKey()) progress.startSession(ctx.cwd);
2026
+ refresh();
2027
+ return true;
2028
+ };
1719
2029
  const setVisibleSegments = (segments: readonly SegmentName[], ctx?: ExtensionContext) => {
1720
2030
  const previousProgressVisible = visibleSegments.includes("progress");
1721
2031
  visibleSegments = serializeSegments(segments);
@@ -1726,6 +2036,10 @@ export default function (pi: ExtensionAPI) {
1726
2036
  refresh();
1727
2037
  };
1728
2038
  const openSegmentConfigurator = async (ctx: ExtensionContext) => {
2039
+ if (ctx.mode !== "tui") {
2040
+ ctx.ui.notify("/bar configuration requires TUI mode", "warning");
2041
+ return;
2042
+ }
1729
2043
  await ctx.ui.custom((tui, theme, _kb, done) => {
1730
2044
  const knownStatusKeys = getKnownStatusKeys(statusFilter, seenStatusKeys);
1731
2045
  const segmentVisibility = new Map(
@@ -1765,13 +2079,27 @@ export default function (pi: ExtensionAPI) {
1765
2079
  persistStatusFilter();
1766
2080
  };
1767
2081
 
1768
- const segmentItems: SettingItem[] = ALL_SEGMENTS.map((segment): SettingItem => ({
1769
- id: `segment:${segment}`,
1770
- label: SEGMENT_LABELS[segment],
1771
- description: "Footer segment visibility",
1772
- currentValue: segmentVisibility.get(segment) ? "shown" : "hidden",
1773
- values: ["shown", "hidden"],
1774
- }));
2082
+ const segmentItems: SettingItem[] = ALL_SEGMENTS.flatMap((segment): SettingItem[] => {
2083
+ const visibilityItem: SettingItem = {
2084
+ id: `segment:${segment}`,
2085
+ label: SEGMENT_LABELS[segment],
2086
+ description: "Footer segment visibility",
2087
+ currentValue: segmentVisibility.get(segment) ? "shown" : "hidden",
2088
+ values: ["shown", "hidden"],
2089
+ };
2090
+ if (segment !== "progress") return [visibilityItem];
2091
+ return [visibilityItem, {
2092
+ id: "progress-model",
2093
+ label: "Progress model",
2094
+ description: process.env.PI_BAR_PROGRESS_MODEL
2095
+ ? "Controlled by PI_BAR_PROGRESS_MODEL (read-only). Unset it and restart Pi to choose here."
2096
+ : "Choose Auto or search models with configured Pi credentials. Saved for all projects; changes only progress updates.",
2097
+ currentValue: formatProgressModelKey(resolveProgressModelPreference(ctx.cwd)),
2098
+ submenu: process.env.PI_BAR_PROGRESS_MODEL
2099
+ ? undefined
2100
+ : (currentValue, close) => createProgressModelPicker(ctx, currentValue, close),
2101
+ }];
2102
+ });
1775
2103
  const statusItems: SettingItem[] = knownStatusKeys.length > 0
1776
2104
  ? [
1777
2105
  {
@@ -1783,26 +2111,36 @@ export default function (pi: ExtensionAPI) {
1783
2111
  },
1784
2112
  ...knownStatusKeys.map((key): SettingItem => ({
1785
2113
  id: `status:${key}`,
1786
- label: `Status: ${key}`,
1787
- description: "Extension status visibility",
2114
+ label: `Status: ${stripTerminalControls(key)}`,
2115
+ description: statusDescription(key),
1788
2116
  currentValue: statusVisibility.get(key) ? "shown" : "hidden",
1789
2117
  values: ["shown", "hidden"],
1790
2118
  })),
1791
2119
  ]
1792
2120
  : [];
1793
- const items: SettingItem[] = [...segmentItems, ...statusItems];
2121
+ const items: SettingItem[] = [
2122
+ ...segmentItems,
2123
+ {
2124
+ id: "show-provider",
2125
+ label: "Show provider",
2126
+ description: "Provider prefix inside the model segment (hidden by default)",
2127
+ currentValue: showProvider ? "shown" : "hidden",
2128
+ values: ["shown", "hidden"],
2129
+ },
2130
+ ...statusItems,
2131
+ ];
1794
2132
 
1795
2133
  const container = new Container();
1796
2134
  container.addChild(
1797
2135
  new (class {
1798
2136
  render(_width: number) {
1799
2137
  return [
1800
- theme.fg("accent", theme.bold("pi-bar visibility")),
2138
+ theme.fg("accent", theme.bold("pi-bar configuration")),
1801
2139
  theme.fg(
1802
2140
  "dim",
1803
2141
  knownStatusKeys.length > 0
1804
- ? "Footer segments + extension statuses · Enter/Space toggles · Esc closes"
1805
- : "Footer segments · no extension statuses seen yet · Esc closes",
2142
+ ? "Footer settings + extension statuses · Enter/Space changes · Esc closes"
2143
+ : "Footer settings · Enter/Space changes · Esc closes",
1806
2144
  ),
1807
2145
  "",
1808
2146
  ];
@@ -1816,6 +2154,15 @@ export default function (pi: ExtensionAPI) {
1816
2154
  Math.min(items.length + 2, 18),
1817
2155
  getSettingsListTheme(),
1818
2156
  (id, newValue) => {
2157
+ if (id === "progress-model") {
2158
+ setProgressModel(newValue, ctx);
2159
+ settingsList.updateValue(id, formatProgressModelKey(resolveProgressModelPreference(ctx.cwd)));
2160
+ return;
2161
+ }
2162
+ if (id === "show-provider") {
2163
+ setShowProvider(newValue === "shown");
2164
+ return;
2165
+ }
1819
2166
  if (id.startsWith("segment:")) {
1820
2167
  const segment = id.slice("segment:".length);
1821
2168
  if (!isSegmentName(segment)) return;
@@ -1843,7 +2190,7 @@ export default function (pi: ExtensionAPI) {
1843
2190
 
1844
2191
  return {
1845
2192
  render(width: number) {
1846
- return container.render(width);
2193
+ return container.render(Math.max(8, width)).map((line) => truncateToWidth(line, Math.max(0, width)));
1847
2194
  },
1848
2195
  invalidate() {
1849
2196
  container.invalidate();
@@ -1899,8 +2246,8 @@ export default function (pi: ExtensionAPI) {
1899
2246
  },
1900
2247
  ...knownStatusKeys.map((key): SettingItem => ({
1901
2248
  id: key,
1902
- label: key,
1903
- description: "Extension status visibility",
2249
+ label: stripTerminalControls(key),
2250
+ description: statusDescription(key),
1904
2251
  currentValue: statusVisibility.get(key) ? "shown" : "hidden",
1905
2252
  values: ["shown", "hidden"],
1906
2253
  })),
@@ -1954,7 +2301,17 @@ export default function (pi: ExtensionAPI) {
1954
2301
  };
1955
2302
 
1956
2303
  pi.registerCommand("bar", {
1957
- description: "Configure pi-bar footer visibility",
2304
+ description: "Configure pi-bar footer",
2305
+ getArgumentCompletions: (prefix) => {
2306
+ let modelKeys: string[] = [];
2307
+ if (/^\s*progress-model\s+\S*$/.test(prefix)) {
2308
+ if (!sessionOpen || process.env.PI_BAR_PROGRESS_MODEL) return null;
2309
+ // Read current local availability only for model arguments, never
2310
+ // refresh catalogs or resolve credentials while the user types.
2311
+ modelKeys = completionModelRegistry?.getAvailable().map(formatProgressModelKey) ?? [];
2312
+ }
2313
+ return completeBarArguments(prefix, getKnownStatusKeys(statusFilter, seenStatusKeys), modelKeys);
2314
+ },
1958
2315
  handler: async (args, ctx) => {
1959
2316
  const [section, action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1960
2317
  if (!section || section === "config" || section === "configure" || section === "edit") {
@@ -1966,6 +2323,38 @@ export default function (pi: ExtensionAPI) {
1966
2323
  return;
1967
2324
  }
1968
2325
 
2326
+ if (section === "progress-model") {
2327
+ if (!action) {
2328
+ const value = formatProgressModelKey(resolveProgressModelPreference(ctx.cwd));
2329
+ const override = process.env.PI_BAR_PROGRESS_MODEL ? " (PI_BAR_PROGRESS_MODEL, read-only)" : "";
2330
+ ctx.ui.notify(`pi-bar progress model: ${value}${override}`, "info");
2331
+ return;
2332
+ }
2333
+ const model = parseSerializedProgressModel(action);
2334
+ if (rest.length > 0 || !model) {
2335
+ ctx.ui.notify("Usage: /bar progress-model [auto|provider/model]", "warning");
2336
+ return;
2337
+ }
2338
+ if (!process.env.PI_BAR_PROGRESS_MODEL && model !== "auto" && !ctx.modelRegistry.getAvailable().some(
2339
+ (available) => formatProgressModelKey(available) === model,
2340
+ )) {
2341
+ ctx.ui.notify("Progress model unavailable. Use /login or choose a configured model with Tab.", "warning");
2342
+ return;
2343
+ }
2344
+ if (setProgressModel(model, ctx)) ctx.ui.notify(`pi-bar progress model: ${model}`, "info");
2345
+ return;
2346
+ }
2347
+
2348
+ if (section === "provider") {
2349
+ if (rest.length > 0 || (action !== undefined && action !== "show" && action !== "hide")) {
2350
+ ctx.ui.notify("Usage: /bar provider [show|hide]", "warning");
2351
+ return;
2352
+ }
2353
+ if (action) setShowProvider(action === "show");
2354
+ ctx.ui.notify(`pi-bar provider prefix: ${showProvider ? "shown" : "hidden"}`, "info");
2355
+ return;
2356
+ }
2357
+
1969
2358
  if (section === "segment" || section === "segments" || section === "footer") {
1970
2359
  const segments = splitSegmentNames(rest.join(" "));
1971
2360
  if ((action === "only" || action === "show" || action === "hide") && segments.length === 0) {
@@ -2077,7 +2466,7 @@ export default function (pi: ExtensionAPI) {
2077
2466
  }
2078
2467
 
2079
2468
  ctx.ui.notify(
2080
- "Usage: /bar [config] or /bar segments [list|all|none|only <segments>|show <segments>|hide <segments>] or /bar status [list|all|none|only <keys>|show <keys>|hide <keys>]",
2469
+ "Usage: /bar [config] or /bar segments [list|all|none|only <segments>|show <segments>|hide <segments>] or /bar status [list|all|none|only <keys>|show <keys>|hide <keys>] or /bar provider [show|hide] or /bar progress-model [auto|provider/model]",
2081
2470
  "warning",
2082
2471
  );
2083
2472
  },
@@ -2085,7 +2474,11 @@ export default function (pi: ExtensionAPI) {
2085
2474
 
2086
2475
  pi.on("model_select", async () => refresh());
2087
2476
  pi.on("thinking_level_select", async () => refresh());
2088
- pi.on("turn_end", async () => refresh());
2477
+ // message_end runs before persistence and allows later handlers to replace
2478
+ // usage. Read finalized entries at the synchronized lifecycle boundaries.
2479
+ pi.on("turn_end", async (_event, ctx) => { syncUsage(ctx); refresh(); });
2480
+ pi.on("agent_end", async (_event, ctx) => { syncUsage(ctx); refresh(); });
2481
+ pi.on("session_compact", async (_event, ctx) => { syncUsage(ctx); refresh(); });
2089
2482
  pi.on("before_agent_start", async (event, ctx) => {
2090
2483
  if (visibleSegments.includes("progress")) progress.recordUserMessage(ctx, event.prompt);
2091
2484
  });
@@ -2093,7 +2486,9 @@ export default function (pi: ExtensionAPI) {
2093
2486
  if (visibleSegments.includes("progress")) progress.recordAssistantUpdate(ctx, event.message);
2094
2487
  });
2095
2488
  pi.on("tool_call", async (event, ctx) => {
2489
+ syncUsage(ctx);
2096
2490
  if (visibleSegments.includes("progress")) progress.recordToolCall(ctx, event);
2491
+ refresh();
2097
2492
  });
2098
2493
  pi.on("tool_result", async (event, ctx) => {
2099
2494
  if (visibleSegments.includes("progress")) progress.recordToolResult(ctx, event);
@@ -2109,11 +2504,21 @@ export default function (pi: ExtensionAPI) {
2109
2504
  if (visibleSegments.includes("progress")) progress.startSession(ctx.cwd);
2110
2505
  else progress.shutdown();
2111
2506
  restoreStatusFilter(ctx);
2507
+ showProvider = readGlobalConfig().showProvider ?? false;
2508
+ syncUsage(ctx, true);
2112
2509
  refresh();
2113
2510
  });
2114
2511
 
2115
2512
  pi.on("session_start", async (_event, ctx) => {
2513
+ sessionEpoch++;
2514
+ sessionOpen = true;
2515
+ completionModelRegistry = ctx.modelRegistry;
2516
+ usageTracker.reset();
2517
+ syncUsage(ctx, true);
2518
+ currentStatuses = new Map();
2519
+ seenStatusKeys.clear();
2116
2520
  visibleSegments = readGlobalSegments() ?? DEFAULT_SEGMENTS;
2521
+ showProvider = readGlobalConfig().showProvider ?? false;
2117
2522
  if (visibleSegments.includes("progress")) progress.startSession(ctx.cwd);
2118
2523
  else progress.shutdown();
2119
2524
  restoreStatusFilter(ctx);
@@ -2132,10 +2537,13 @@ export default function (pi: ExtensionAPI) {
2132
2537
  },
2133
2538
  invalidate() {},
2134
2539
  render(width: number): string[] {
2135
- const modelName = formatModelName(ctx.model?.id);
2540
+ const modelName = formatModelName(ctx.model, showProvider);
2541
+ const metrics = usageTracker.snapshot();
2542
+ const reportedUsage = hasUsage(metrics);
2136
2543
  const thinkingLevel = String(pi.getThinkingLevel());
2544
+ currentStatuses = footerData?.getExtensionStatuses?.() ?? new Map();
2137
2545
  const extensionStatusParts = formatExtensionStatuses(
2138
- footerData?.getExtensionStatuses?.() ?? new Map(),
2546
+ currentStatuses,
2139
2547
  statusFilter,
2140
2548
  seenStatusKeys,
2141
2549
  );
@@ -2151,35 +2559,50 @@ export default function (pi: ExtensionAPI) {
2151
2559
  : "—";
2152
2560
  const progressText = progress.text();
2153
2561
 
2154
- const extensionStatuses = extensionStatusParts
2155
- ? extensionStatusParts
2156
- .map((part) => theme.fg("text", part))
2157
- .join(` ${theme.fg("dim", EXTENSION_STATUS_SEPARATOR)} `)
2158
- : null;
2159
- const segmentRenderers: Record<SegmentName, string | null> = {
2160
- model: theme.fg("accent", modelName),
2161
- thinking: theme.fg(thinkingColor(thinkingLevel), `think:${thinkingLevel}`),
2162
- context: theme.fg(contextSegmentColor, contextText),
2163
- cwd: visibleSegments.includes("cwd")
2164
- ? theme.fg("accent", formatCwd(ctx.cwd, homedir(), Math.min(cwdWidth, width)))
2165
- : null,
2166
- progress: progressText ? theme.fg("text", progressText) : null,
2167
- extensions: extensionStatuses,
2562
+ const directories = visibleSegments.includes("cwd")
2563
+ ? cwdVariants(ctx.cwd, homedir(), cwdWidth).map((text) => theme.fg("accent", text))
2564
+ : [];
2565
+ const contextCompact = usage
2566
+ ? usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "—%"
2567
+ : "—";
2568
+ const shortThinking = thinkingLevel === "medium" ? "med" : thinkingLevel === "minimal" ? "min" : thinkingLevel;
2569
+ const renderers: Record<SegmentName, Omit<FooterSegment, "name">> = {
2570
+ model: {
2571
+ text: theme.fg("accent", modelName),
2572
+ alternatives: showProvider ? [theme.fg("accent", formatModelName(ctx.model))] : [],
2573
+ },
2574
+ thinking: {
2575
+ text: theme.fg(thinkingColor(thinkingLevel), `think:${thinkingLevel}`),
2576
+ alternatives: [theme.fg(thinkingColor(thinkingLevel), `think:${shortThinking}`)],
2577
+ },
2578
+ context: {
2579
+ text: theme.fg(contextSegmentColor, contextText),
2580
+ alternatives: [theme.fg(contextSegmentColor, contextCompact)],
2581
+ },
2582
+ cache_hit_ratio: {
2583
+ text: metrics.cacheHitRate === null ? "" : theme.fg("muted", `CH:${metrics.cacheHitRate.toFixed(1).replace(/\.0$/, "")}%`),
2584
+ },
2585
+ cost: { text: reportedUsage ? theme.fg("muted", `≈$${metrics.cost.toFixed(3)}`) : "" },
2586
+ tokens: { text: reportedUsage ? theme.fg("muted", `↑${formatTokens(metrics.input)} ↓${formatTokens(metrics.output)}`) : "" },
2587
+ cwd: { text: directories[0] ?? "", alternatives: directories.slice(1) },
2588
+ progress: { text: progressText ? theme.fg("text", progressText) : "" },
2589
+ extensions: { text: "" },
2168
2590
  };
2169
-
2170
- const separator = ` ${theme.fg("dim", SEGMENT_SEPARATOR)} `;
2171
- const line = visibleSegments
2172
- .map((segment) => segmentRenderers[segment])
2173
- .filter((segment): segment is string => segment !== null)
2174
- .join(separator);
2175
-
2176
- return [truncateToWidth(line, width)];
2591
+ return [layoutFooter(
2592
+ visibleSegments.map((name) => ({ name, ...renderers[name] })),
2593
+ (extensionStatusParts ?? []).map((part) => theme.fg("text", part)),
2594
+ width,
2595
+ theme.fg("dim", SEGMENT_SEPARATOR),
2596
+ )];
2177
2597
  },
2178
2598
  };
2179
2599
  });
2180
2600
  });
2181
2601
 
2182
2602
  pi.on("session_shutdown", async (_event, ctx) => {
2603
+ sessionOpen = false;
2604
+ completionModelRegistry = undefined;
2605
+ sessionEpoch++;
2183
2606
  progress.shutdown();
2184
2607
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
2185
2608
  });
@@ -0,0 +1,108 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Numeric-only entries; never add progress prompts or responses to session context. */
5
+ export const PROGRESS_USAGE_ENTRY_TYPE = "pi-bar-progress-usage";
6
+
7
+ export type UsageTotals = {
8
+ /** All prompt tokens, including cache reads and writes. */
9
+ input: number;
10
+ /** Includes reasoning tokens when reported as part of output. */
11
+ output: number;
12
+ /** Reported/catalog dollar estimate, not an invoice. */
13
+ cost: number;
14
+ };
15
+
16
+ function nonNegative(value: unknown): number {
17
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
18
+ }
19
+
20
+ export function totalsFromUsage(usage: Partial<Usage> | undefined): UsageTotals {
21
+ return {
22
+ input: nonNegative(usage?.input) + nonNegative(usage?.cacheRead) + nonNegative(usage?.cacheWrite),
23
+ output: nonNegative(usage?.output),
24
+ cost: nonNegative(usage?.cost?.total),
25
+ };
26
+ }
27
+
28
+ export function hasUsage(totals: UsageTotals): boolean {
29
+ return totals.input > 0 || totals.output > 0 || totals.cost > 0;
30
+ }
31
+
32
+ export function promptCacheHitRate(usage: Partial<Usage> | undefined): number | null {
33
+ const promptTokens = totalsFromUsage(usage).input;
34
+ return promptTokens > 0 && Number.isFinite(promptTokens)
35
+ ? (nonNegative(usage?.cacheRead) / promptTokens) * 100
36
+ : null;
37
+ }
38
+
39
+ function totalsFromEntry(entry: SessionEntry): UsageTotals {
40
+ if (entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "toolResult")) {
41
+ return totalsFromUsage(entry.message.usage);
42
+ }
43
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
44
+ return totalsFromUsage(entry.usage);
45
+ }
46
+ if (entry.type === "custom" && entry.customType === PROGRESS_USAGE_ENTRY_TYPE) {
47
+ const data = entry.data as Partial<UsageTotals> | undefined;
48
+ if (data && [data.input, data.output, data.cost].every(
49
+ (value) => typeof value === "number" && Number.isFinite(value) && value >= 0,
50
+ )) {
51
+ return { input: data.input!, output: data.output!, cost: data.cost! };
52
+ }
53
+ }
54
+ return { input: 0, output: 0, cost: 0 };
55
+ }
56
+
57
+ /**
58
+ * Totals cover the entire session file, including abandoned branches. Cache hit
59
+ * rate follows the active branch instead. Consume persisted entries after Pi's
60
+ * message_end hooks finish, so later extensions' usage corrections are honored.
61
+ * Rendering only reads a snapshot; it never walks session history.
62
+ */
63
+ export class FooterUsageTracker {
64
+ private totals: UsageTotals = { input: 0, output: 0, cost: 0 };
65
+ private cacheHitRate: number | null = null;
66
+ private cursor = 0;
67
+ private lastEntryId: string | undefined;
68
+
69
+ reset(): void {
70
+ this.totals = { input: 0, output: 0, cost: 0 };
71
+ this.cacheHitRate = null;
72
+ this.cursor = 0;
73
+ this.lastEntryId = undefined;
74
+ }
75
+
76
+ syncEntries(entries: readonly SessionEntry[]): void {
77
+ // Session replacement or a rewritten prefix requires a fresh snapshot.
78
+ if (entries.length < this.cursor || (this.cursor > 0 && entries[this.cursor - 1]?.id !== this.lastEntryId)) {
79
+ this.reset();
80
+ }
81
+ for (; this.cursor < entries.length; this.cursor++) {
82
+ const entry = entries[this.cursor];
83
+ const totals = totalsFromEntry(entry);
84
+ this.totals.input += totals.input;
85
+ this.totals.output += totals.output;
86
+ this.totals.cost += totals.cost;
87
+ if (entry.type === "message" && entry.message.role === "assistant") {
88
+ this.cacheHitRate = promptCacheHitRate(entry.message.usage);
89
+ }
90
+ }
91
+ this.lastEntryId = entries.at(-1)?.id;
92
+ }
93
+
94
+ restoreBranch(branch: readonly SessionEntry[]): void {
95
+ this.cacheHitRate = null;
96
+ for (let index = branch.length - 1; index >= 0; index--) {
97
+ const entry = branch[index];
98
+ if (entry.type === "message" && entry.message.role === "assistant") {
99
+ this.cacheHitRate = promptCacheHitRate(entry.message.usage);
100
+ break;
101
+ }
102
+ }
103
+ }
104
+
105
+ snapshot(): UsageTotals & { cacheHitRate: number | null } {
106
+ return { ...this.totals, cacheHitRate: this.cacheHitRate };
107
+ }
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bar",
3
- "version": "0.3.41",
3
+ "version": "0.3.44",
4
4
  "description": "Pi footer/statusline showing active model, thinking level, context pressure, live progress updates, and extension statuses.",
5
5
  "type": "module",
6
6
  "keywords": [