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 +71 -8
- package/extensions/status-footer.ts +344 -31
- package/extensions/usage.ts +108 -0
- package/package.json +1 -1
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
|
|
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`
|
|
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
|
|
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=
|
|
171
|
+
PI_BAR_PROGRESS_MODEL=openai/gpt-4.1-mini pi
|
|
109
172
|
```
|
|
110
173
|
|
|
111
|
-
|
|
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
|
|
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(
|
|
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.
|
|
155
|
-
|
|
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(
|
|
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
|
|
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.
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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[] = [
|
|
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
|
|
2138
|
+
theme.fg("accent", theme.bold("pi-bar configuration")),
|
|
1906
2139
|
theme.fg(
|
|
1907
2140
|
"dim",
|
|
1908
2141
|
knownStatusKeys.length > 0
|
|
1909
|
-
? "Footer
|
|
1910
|
-
: "Footer
|
|
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
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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