pi-bar 0.3.41 → 0.3.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -8
- package/extensions/status-footer.ts +483 -60
- package/extensions/usage.ts +108 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
**Never accidentally run Opus on a typo again.** pi-bar keeps your model, thinking level, context pressure, a live progress update, and any extension statuses visible in pi's footer.
|
|
7
7
|
|
|
8
8
|
```text
|
|
9
|
-
claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Reviewing package structure ❯
|
|
9
|
+
claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Reviewing package structure ❯ Plan active ❯ Queue: 2
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|

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