pi-bar 0.3.43 → 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
@@ -41,12 +41,13 @@ The footer adapts to terminal width without extra configuration:
41
41
 
42
42
  - **Model, thinking level, and context percentage take priority.**
43
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.
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.
45
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.
46
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.
47
48
  - Widening the terminal restores the full display. Thinking and context retain their semantic colors throughout.
48
49
 
49
- Existing segment visibility settings still apply; CWD remains opt-in.
50
+ Existing visibility settings still apply. CWD, usage metrics, and the provider prefix are opt-in; the default footer is unchanged.
50
51
 
51
52
  ## Customization
52
53
 
@@ -56,7 +57,7 @@ pi-bar works out of the box. Run `/bar` inside pi to choose which footer segment
56
57
  /bar
57
58
  ```
58
59
 
59
- 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:
60
61
 
61
62
  ```text
62
63
  /bar segments list
@@ -65,7 +66,9 @@ Toggle `Model`, `Thinking level`, `Context usage`, `Current directory`, `Progres
65
66
  /bar segments show thinking
66
67
  ```
67
68
 
68
- 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`.
69
72
 
70
73
  You can also set startup defaults with environment variables before launching pi:
71
74
 
@@ -98,17 +101,77 @@ The cap must be an integer of at least 8; invalid values use 36. Very long direc
98
101
 
99
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.
100
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
+
101
148
  ### Configure live progress updates
102
149
 
103
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.
104
151
 
105
- 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:
106
169
 
107
170
  ```bash
108
- PI_BAR_PROGRESS_MODEL=anthropic/claude-haiku-4-5 pi
171
+ PI_BAR_PROGRESS_MODEL=openai/gpt-4.1-mini pi
109
172
  ```
110
173
 
111
- 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.
112
175
 
113
176
  ### Configure extension statuses
114
177
 
@@ -145,7 +208,7 @@ npm test
145
208
  npm run check
146
209
  ```
147
210
 
148
- Tests cover path formatting, display-ready statuses, responsive layout, terminal safety, ANSI/Unicode width limits, resize behavior, and segment/status 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.
149
212
 
150
213
  ## Security note
151
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",
@@ -149,10 +159,17 @@ function formatTokens(n: number): string {
149
159
  return `${n}`;
150
160
  }
151
161
 
152
- 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 ?? "");
153
167
  if (!id) return "no-model";
154
- const base = id.includes("/") ? (id.split("/").pop() ?? id) : id;
155
- 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;
156
173
  }
157
174
 
158
175
  const DEFAULT_CWD_MAX_WIDTH = 36;
@@ -254,7 +271,7 @@ export function layoutFooter(
254
271
  separator = ` ${divider} `;
255
272
  updateBadges();
256
273
  }
257
- for (const name of ["cwd", "context", "thinking"] as const) {
274
+ for (const name of ["cwd", "context", "thinking", "model"] as const) {
258
275
  const item = items.find((item) => item.name === name);
259
276
  if (!item) continue;
260
277
  for (const alternative of item.alternatives ?? []) {
@@ -262,6 +279,12 @@ export function layoutFooter(
262
279
  if (visibleWidth(alternative) < visibleWidth(item.text)) item.text = alternative;
263
280
  }
264
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
+ }
265
288
  while (extensions && shownBadges > 0 && overflow() > 0) {
266
289
  shownBadges--;
267
290
  updateBadges();
@@ -282,7 +305,7 @@ export function layoutFooter(
282
305
  }
283
306
  // Whole-badge overflow can free more space than needed. Use that space rather
284
307
  // than leaving unnecessarily compact context, paths, or progress on screen.
285
- for (const name of ["context", "thinking", "cwd", "progress"] as const) {
308
+ for (const name of ["model", "context", "thinking", "cwd", "progress"] as const) {
286
309
  const item = items.find((item) => item.name === name);
287
310
  const original = segments.find((segment) => segment.name === name);
288
311
  if (!item?.text || !original) continue;
@@ -380,7 +403,7 @@ function parseThresholds(): { warningThreshold: number; errorThreshold: number }
380
403
 
381
404
  function parseProgressModelSpec(value: string): ProgressModelPreference | undefined {
382
405
  const trimmed = value.trim();
383
- if (!trimmed || trimmed === "auto") return undefined;
406
+ if (!trimmed || trimmed === "auto" || /[\s\x00-\x1f\x7f-\x9f]/u.test(trimmed)) return undefined;
384
407
  const separator = trimmed.indexOf("/");
385
408
  if (separator <= 0 || separator === trimmed.length - 1) return undefined;
386
409
  return { provider: trimmed.slice(0, separator), id: trimmed.slice(separator + 1) };
@@ -402,10 +425,14 @@ function settingsModelValue(settings: Record<string, unknown>): string | undefin
402
425
  return undefined;
403
426
  }
404
427
 
405
- function resolveProgressModelPreference(cwd: string): ProgressModelPreference | undefined {
428
+ export function resolveProgressModelPreference(cwd: string): ProgressModelPreference | undefined {
406
429
  const envModel = process.env.PI_BAR_PROGRESS_MODEL;
407
430
  if (envModel) return parseProgressModelSpec(envModel);
408
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
+
409
436
  const settings = SettingsManager.create(cwd);
410
437
  const projectModel = settingsModelValue(
411
438
  settings.getProjectSettings() as Record<string, unknown>,
@@ -428,8 +455,64 @@ const FAST_PROGRESS_MODELS: readonly ProgressModelPreference[] = [
428
455
  { provider: "anthropic", id: "claude-haiku-4-5-20251001" },
429
456
  ];
430
457
 
431
- function formatProgressModelKey(model: ProgressModelPreference): string {
432
- 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;
433
516
  }
434
517
 
435
518
  async function getModelAuth(
@@ -807,12 +890,19 @@ class FooterProgressEngine {
807
890
  private normalCheckpointTimer?: ReturnType<typeof setTimeout>;
808
891
  private normalCheckpointBurstStartedAt?: number;
809
892
 
810
- constructor(private readonly requestRender: () => void) {}
893
+ constructor(
894
+ private readonly requestRender: () => void,
895
+ private readonly createUsageRecorder: (ctx: ExtensionContext) => (usage: Usage) => void,
896
+ ) {}
811
897
 
812
898
  text(): string | null {
813
899
  return this.currentText;
814
900
  }
815
901
 
902
+ modelKey(): string {
903
+ return formatProgressModelKey(this.configuredModel);
904
+ }
905
+
816
906
  startSession(cwd: string): void {
817
907
  this.configuredModel = resolveProgressModelPreference(cwd);
818
908
  this.facts.resetConversation();
@@ -1058,6 +1148,7 @@ class FooterProgressEngine {
1058
1148
 
1059
1149
  abortController = new AbortController();
1060
1150
  this.abortController = abortController;
1151
+ const recordUsage = this.createUsageRecorder(ctx);
1061
1152
  const response = await complete(
1062
1153
  auth.model,
1063
1154
  { systemPrompt: checkpointSystemPrompt(job), messages: [prompt] },
@@ -1071,6 +1162,9 @@ class FooterProgressEngine {
1071
1162
  signal: abortController.signal,
1072
1163
  },
1073
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);
1074
1168
  if (!this.isCurrentJob(job) || response.stopReason !== "stop") return;
1075
1169
 
1076
1170
  const rawText = extractTextContent(response.content) ?? "";
@@ -1754,6 +1848,8 @@ function readGlobalConfig(): GlobalBarConfig {
1754
1848
  return {
1755
1849
  statusFilter: statusFilter ? serializeStatusFilter(statusFilter) : undefined,
1756
1850
  segments: parseSerializedSegments(data.segments) ?? undefined,
1851
+ showProvider: typeof data.showProvider === "boolean" ? data.showProvider : undefined,
1852
+ progressModel: parseSerializedProgressModel(data.progressModel),
1757
1853
  };
1758
1854
  } catch {
1759
1855
  return {};
@@ -1786,6 +1882,68 @@ function writeGlobalSegments(segments: readonly SegmentName[]): void {
1786
1882
  writeGlobalConfig({ ...existing, segments: serializeSegments(segments) });
1787
1883
  }
1788
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
+
1789
1947
  function getKnownStatusKeys(filter: StatusFilter, seenStatusKeys: Set<string>): string[] {
1790
1948
  const keys = new Set(seenStatusKeys);
1791
1949
  if (filter.mode === "only") {
@@ -1800,12 +1958,36 @@ export default function (pi: ExtensionAPI) {
1800
1958
  let requestRender: (() => void) | undefined;
1801
1959
  let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
1802
1960
  let visibleSegments: SegmentName[] = readGlobalSegments() ?? DEFAULT_SEGMENTS;
1961
+ let showProvider = readGlobalConfig().showProvider ?? false;
1803
1962
  const seenStatusKeys = new Set<string>();
1804
1963
  let currentStatuses: ReadonlyMap<string, string> = new Map();
1964
+ let completionModelRegistry: ExtensionContext["modelRegistry"] | undefined;
1805
1965
  const statusDescription = (key: string) =>
1806
1966
  stripTerminalControls(currentStatuses.get(key) ?? "") || "No current status text";
1807
1967
  const refresh = () => requestRender?.();
1808
- 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
+ });
1809
1991
  const restoreStatusFilter = (ctx: ExtensionContext) => {
1810
1992
  let restoredFilter = readGlobalStatusFilter();
1811
1993
  if (!restoredFilter) {
@@ -1821,6 +2003,29 @@ export default function (pi: ExtensionAPI) {
1821
2003
  writeGlobalStatusFilter(statusFilter);
1822
2004
  refresh();
1823
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
+ };
1824
2029
  const setVisibleSegments = (segments: readonly SegmentName[], ctx?: ExtensionContext) => {
1825
2030
  const previousProgressVisible = visibleSegments.includes("progress");
1826
2031
  visibleSegments = serializeSegments(segments);
@@ -1831,6 +2036,10 @@ export default function (pi: ExtensionAPI) {
1831
2036
  refresh();
1832
2037
  };
1833
2038
  const openSegmentConfigurator = async (ctx: ExtensionContext) => {
2039
+ if (ctx.mode !== "tui") {
2040
+ ctx.ui.notify("/bar configuration requires TUI mode", "warning");
2041
+ return;
2042
+ }
1834
2043
  await ctx.ui.custom((tui, theme, _kb, done) => {
1835
2044
  const knownStatusKeys = getKnownStatusKeys(statusFilter, seenStatusKeys);
1836
2045
  const segmentVisibility = new Map(
@@ -1870,13 +2079,27 @@ export default function (pi: ExtensionAPI) {
1870
2079
  persistStatusFilter();
1871
2080
  };
1872
2081
 
1873
- const segmentItems: SettingItem[] = ALL_SEGMENTS.map((segment): SettingItem => ({
1874
- id: `segment:${segment}`,
1875
- label: SEGMENT_LABELS[segment],
1876
- description: "Footer segment visibility",
1877
- currentValue: segmentVisibility.get(segment) ? "shown" : "hidden",
1878
- values: ["shown", "hidden"],
1879
- }));
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
+ });
1880
2103
  const statusItems: SettingItem[] = knownStatusKeys.length > 0
1881
2104
  ? [
1882
2105
  {
@@ -1895,19 +2118,29 @@ export default function (pi: ExtensionAPI) {
1895
2118
  })),
1896
2119
  ]
1897
2120
  : [];
1898
- 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
+ ];
1899
2132
 
1900
2133
  const container = new Container();
1901
2134
  container.addChild(
1902
2135
  new (class {
1903
2136
  render(_width: number) {
1904
2137
  return [
1905
- theme.fg("accent", theme.bold("pi-bar visibility")),
2138
+ theme.fg("accent", theme.bold("pi-bar configuration")),
1906
2139
  theme.fg(
1907
2140
  "dim",
1908
2141
  knownStatusKeys.length > 0
1909
- ? "Footer segments + extension statuses · Enter/Space toggles · Esc closes"
1910
- : "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",
1911
2144
  ),
1912
2145
  "",
1913
2146
  ];
@@ -1921,6 +2154,15 @@ export default function (pi: ExtensionAPI) {
1921
2154
  Math.min(items.length + 2, 18),
1922
2155
  getSettingsListTheme(),
1923
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
+ }
1924
2166
  if (id.startsWith("segment:")) {
1925
2167
  const segment = id.slice("segment:".length);
1926
2168
  if (!isSegmentName(segment)) return;
@@ -1948,7 +2190,7 @@ export default function (pi: ExtensionAPI) {
1948
2190
 
1949
2191
  return {
1950
2192
  render(width: number) {
1951
- return container.render(width);
2193
+ return container.render(Math.max(8, width)).map((line) => truncateToWidth(line, Math.max(0, width)));
1952
2194
  },
1953
2195
  invalidate() {
1954
2196
  container.invalidate();
@@ -2059,7 +2301,17 @@ export default function (pi: ExtensionAPI) {
2059
2301
  };
2060
2302
 
2061
2303
  pi.registerCommand("bar", {
2062
- 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
+ },
2063
2315
  handler: async (args, ctx) => {
2064
2316
  const [section, action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
2065
2317
  if (!section || section === "config" || section === "configure" || section === "edit") {
@@ -2071,6 +2323,38 @@ export default function (pi: ExtensionAPI) {
2071
2323
  return;
2072
2324
  }
2073
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
+
2074
2358
  if (section === "segment" || section === "segments" || section === "footer") {
2075
2359
  const segments = splitSegmentNames(rest.join(" "));
2076
2360
  if ((action === "only" || action === "show" || action === "hide") && segments.length === 0) {
@@ -2182,7 +2466,7 @@ export default function (pi: ExtensionAPI) {
2182
2466
  }
2183
2467
 
2184
2468
  ctx.ui.notify(
2185
- "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]",
2186
2470
  "warning",
2187
2471
  );
2188
2472
  },
@@ -2190,7 +2474,11 @@ export default function (pi: ExtensionAPI) {
2190
2474
 
2191
2475
  pi.on("model_select", async () => refresh());
2192
2476
  pi.on("thinking_level_select", async () => refresh());
2193
- 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(); });
2194
2482
  pi.on("before_agent_start", async (event, ctx) => {
2195
2483
  if (visibleSegments.includes("progress")) progress.recordUserMessage(ctx, event.prompt);
2196
2484
  });
@@ -2198,7 +2486,9 @@ export default function (pi: ExtensionAPI) {
2198
2486
  if (visibleSegments.includes("progress")) progress.recordAssistantUpdate(ctx, event.message);
2199
2487
  });
2200
2488
  pi.on("tool_call", async (event, ctx) => {
2489
+ syncUsage(ctx);
2201
2490
  if (visibleSegments.includes("progress")) progress.recordToolCall(ctx, event);
2491
+ refresh();
2202
2492
  });
2203
2493
  pi.on("tool_result", async (event, ctx) => {
2204
2494
  if (visibleSegments.includes("progress")) progress.recordToolResult(ctx, event);
@@ -2214,11 +2504,21 @@ export default function (pi: ExtensionAPI) {
2214
2504
  if (visibleSegments.includes("progress")) progress.startSession(ctx.cwd);
2215
2505
  else progress.shutdown();
2216
2506
  restoreStatusFilter(ctx);
2507
+ showProvider = readGlobalConfig().showProvider ?? false;
2508
+ syncUsage(ctx, true);
2217
2509
  refresh();
2218
2510
  });
2219
2511
 
2220
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();
2221
2520
  visibleSegments = readGlobalSegments() ?? DEFAULT_SEGMENTS;
2521
+ showProvider = readGlobalConfig().showProvider ?? false;
2222
2522
  if (visibleSegments.includes("progress")) progress.startSession(ctx.cwd);
2223
2523
  else progress.shutdown();
2224
2524
  restoreStatusFilter(ctx);
@@ -2237,7 +2537,9 @@ export default function (pi: ExtensionAPI) {
2237
2537
  },
2238
2538
  invalidate() {},
2239
2539
  render(width: number): string[] {
2240
- const modelName = formatModelName(ctx.model?.id);
2540
+ const modelName = formatModelName(ctx.model, showProvider);
2541
+ const metrics = usageTracker.snapshot();
2542
+ const reportedUsage = hasUsage(metrics);
2241
2543
  const thinkingLevel = String(pi.getThinkingLevel());
2242
2544
  currentStatuses = footerData?.getExtensionStatuses?.() ?? new Map();
2243
2545
  const extensionStatusParts = formatExtensionStatuses(
@@ -2265,7 +2567,10 @@ export default function (pi: ExtensionAPI) {
2265
2567
  : "—";
2266
2568
  const shortThinking = thinkingLevel === "medium" ? "med" : thinkingLevel === "minimal" ? "min" : thinkingLevel;
2267
2569
  const renderers: Record<SegmentName, Omit<FooterSegment, "name">> = {
2268
- model: { text: theme.fg("accent", stripTerminalControls(modelName)) },
2570
+ model: {
2571
+ text: theme.fg("accent", modelName),
2572
+ alternatives: showProvider ? [theme.fg("accent", formatModelName(ctx.model))] : [],
2573
+ },
2269
2574
  thinking: {
2270
2575
  text: theme.fg(thinkingColor(thinkingLevel), `think:${thinkingLevel}`),
2271
2576
  alternatives: [theme.fg(thinkingColor(thinkingLevel), `think:${shortThinking}`)],
@@ -2274,6 +2579,11 @@ export default function (pi: ExtensionAPI) {
2274
2579
  text: theme.fg(contextSegmentColor, contextText),
2275
2580
  alternatives: [theme.fg(contextSegmentColor, contextCompact)],
2276
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)}`) : "" },
2277
2587
  cwd: { text: directories[0] ?? "", alternatives: directories.slice(1) },
2278
2588
  progress: { text: progressText ? theme.fg("text", progressText) : "" },
2279
2589
  extensions: { text: "" },
@@ -2290,6 +2600,9 @@ export default function (pi: ExtensionAPI) {
2290
2600
  });
2291
2601
 
2292
2602
  pi.on("session_shutdown", async (_event, ctx) => {
2603
+ sessionOpen = false;
2604
+ completionModelRegistry = undefined;
2605
+ sessionEpoch++;
2293
2606
  progress.shutdown();
2294
2607
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
2295
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.43",
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": [