pi-bar 0.3.43 → 0.3.45

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.
@@ -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,10 +135,43 @@ 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
- extensions: "Extension statuses",
143
+ extensions: "Extension badges",
134
144
  };
145
+ const SEGMENT_DESCRIPTIONS: Record<SegmentName, string> = {
146
+ model: "Show the current chat model's name. This does not change your model.",
147
+ thinking: "Show the current thinking level, with its usual color.",
148
+ context: "Show context usage and the model's context window.",
149
+ cache_hit_ratio: "Show the latest response's prompt cache-hit rate. Hidden until prompt usage is available.",
150
+ cost: "Show estimated recorded session cost, not an invoice. Hidden until usage is available.",
151
+ tokens: "Show recorded input and output token totals, including cache usage.",
152
+ cwd: "Show this session's directory, not the directory of a tool's shell.",
153
+ progress: "Show live activity updates. Uses extra model calls; choose the progress model below.",
154
+ extensions: "Show badges published by other extensions. Choose individual badges below.",
155
+ };
156
+ // Advertise tasks, not the legacy command groups. Old spellings still work.
157
+ const BAR_COMMAND_DESCRIPTIONS = new Map([
158
+ ["settings", "Open all footer settings"],
159
+ ["show", "Show footer items"],
160
+ ["hide", "Hide footer items"],
161
+ ["badges", "Choose extension badges"],
162
+ ["provider", "Show or hide provider prefix"],
163
+ ["progress-model", "Choose progress model"],
164
+ ["help", "Commands and examples"],
165
+ ]);
166
+ const BAR_ACTION_DESCRIPTIONS = new Map([
167
+ ["show", "Show selected items"],
168
+ ["hide", "Hide selected items"],
169
+ ["only", "Show only selected items"],
170
+ ["all", "Show all items"],
171
+ ["none", "Hide all items"],
172
+ ["list", "Inspect visibility without changing it"],
173
+ ["settings", "Open visibility settings"],
174
+ ]);
135
175
  const DEFAULT_WARNING_THRESHOLD = 70;
136
176
  const DEFAULT_ERROR_THRESHOLD = 90;
137
177
 
@@ -149,10 +189,17 @@ function formatTokens(n: number): string {
149
189
  return `${n}`;
150
190
  }
151
191
 
152
- function formatModelName(id: string | undefined): string {
192
+ export function formatModelName(
193
+ model: { id: string; provider?: string } | undefined,
194
+ showProvider = false,
195
+ ): string {
196
+ const id = stripTerminalControls(model?.id ?? "");
153
197
  if (!id) return "no-model";
154
- const base = id.includes("/") ? (id.split("/").pop() ?? id) : id;
155
- return base.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
198
+ const base = id.split("/").filter(Boolean).at(-1) ?? id;
199
+ const name = base.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
200
+ // The namespace in a model ID is not necessarily its routing provider.
201
+ const provider = stripTerminalControls(model?.provider ?? "");
202
+ return showProvider && provider ? `${provider}/${name}` : name;
156
203
  }
157
204
 
158
205
  const DEFAULT_CWD_MAX_WIDTH = 36;
@@ -254,7 +301,7 @@ export function layoutFooter(
254
301
  separator = ` ${divider} `;
255
302
  updateBadges();
256
303
  }
257
- for (const name of ["cwd", "context", "thinking"] as const) {
304
+ for (const name of ["cwd", "context", "thinking", "model"] as const) {
258
305
  const item = items.find((item) => item.name === name);
259
306
  if (!item) continue;
260
307
  for (const alternative of item.alternatives ?? []) {
@@ -262,6 +309,12 @@ export function layoutFooter(
262
309
  if (visibleWidth(alternative) < visibleWidth(item.text)) item.text = alternative;
263
310
  }
264
311
  }
312
+ // Optional metrics yield whole, never leaving a misleading partial number.
313
+ for (const name of ["tokens", "cost", "cache_hit_ratio"] as const) {
314
+ if (overflow() <= 0) break;
315
+ const item = items.find((item) => item.name === name);
316
+ if (item) item.text = "";
317
+ }
265
318
  while (extensions && shownBadges > 0 && overflow() > 0) {
266
319
  shownBadges--;
267
320
  updateBadges();
@@ -282,7 +335,7 @@ export function layoutFooter(
282
335
  }
283
336
  // Whole-badge overflow can free more space than needed. Use that space rather
284
337
  // than leaving unnecessarily compact context, paths, or progress on screen.
285
- for (const name of ["context", "thinking", "cwd", "progress"] as const) {
338
+ for (const name of ["model", "context", "thinking", "cwd", "progress"] as const) {
286
339
  const item = items.find((item) => item.name === name);
287
340
  const original = segments.find((segment) => segment.name === name);
288
341
  if (!item?.text || !original) continue;
@@ -334,6 +387,19 @@ function contextColor(
334
387
  return "success";
335
388
  }
336
389
 
390
+ // Cache hit ratio is "higher is better", the inverse of context usage. A healthy
391
+ // multi-turn conversation typically reads 90%+ of its prompt from cache; below
392
+ // 50% means most of the prompt was billed as fresh input (a mostly-miss turn).
393
+ const CACHE_HIT_GOOD_THRESHOLD = 90;
394
+ const CACHE_HIT_POOR_THRESHOLD = 50;
395
+
396
+ function cacheHitColor(percent: number | null | undefined): ThemeColor {
397
+ if (percent === null || percent === undefined) return "muted";
398
+ if (percent >= CACHE_HIT_GOOD_THRESHOLD) return "success";
399
+ if (percent >= CACHE_HIT_POOR_THRESHOLD) return "warning";
400
+ return "error";
401
+ }
402
+
337
403
  function isSegmentName(value: string): value is SegmentName {
338
404
  return (ALL_SEGMENTS as readonly string[]).includes(value);
339
405
  }
@@ -380,7 +446,7 @@ function parseThresholds(): { warningThreshold: number; errorThreshold: number }
380
446
 
381
447
  function parseProgressModelSpec(value: string): ProgressModelPreference | undefined {
382
448
  const trimmed = value.trim();
383
- if (!trimmed || trimmed === "auto") return undefined;
449
+ if (!trimmed || trimmed === "auto" || /[\s\x00-\x1f\x7f-\x9f]/u.test(trimmed)) return undefined;
384
450
  const separator = trimmed.indexOf("/");
385
451
  if (separator <= 0 || separator === trimmed.length - 1) return undefined;
386
452
  return { provider: trimmed.slice(0, separator), id: trimmed.slice(separator + 1) };
@@ -402,10 +468,14 @@ function settingsModelValue(settings: Record<string, unknown>): string | undefin
402
468
  return undefined;
403
469
  }
404
470
 
405
- function resolveProgressModelPreference(cwd: string): ProgressModelPreference | undefined {
471
+ export function resolveProgressModelPreference(cwd: string): ProgressModelPreference | undefined {
406
472
  const envModel = process.env.PI_BAR_PROGRESS_MODEL;
407
473
  if (envModel) return parseProgressModelSpec(envModel);
408
474
 
475
+ // An explicit /bar choice wins over legacy Pi settings, including "auto".
476
+ const savedModel = readGlobalConfig().progressModel;
477
+ if (savedModel !== undefined) return parseProgressModelSpec(savedModel);
478
+
409
479
  const settings = SettingsManager.create(cwd);
410
480
  const projectModel = settingsModelValue(
411
481
  settings.getProjectSettings() as Record<string, unknown>,
@@ -428,8 +498,64 @@ const FAST_PROGRESS_MODELS: readonly ProgressModelPreference[] = [
428
498
  { provider: "anthropic", id: "claude-haiku-4-5-20251001" },
429
499
  ];
430
500
 
431
- function formatProgressModelKey(model: ProgressModelPreference): string {
432
- return `${model.provider}/${model.id}`;
501
+ function formatProgressModelKey(model: ProgressModelPreference | undefined): string {
502
+ return model ? `${model.provider}/${model.id}` : "auto";
503
+ }
504
+
505
+ function parseSerializedProgressModel(value: unknown): string | undefined {
506
+ if (typeof value !== "string") return undefined;
507
+ if (value.trim() === "auto") return "auto";
508
+ const model = parseProgressModelSpec(value);
509
+ return model ? formatProgressModelKey(model) : undefined;
510
+ }
511
+
512
+ function createProgressModelPicker(
513
+ ctx: ExtensionContext,
514
+ currentValue: string,
515
+ done: (value?: string) => void,
516
+ ): SettingsList {
517
+ // Availability is a local snapshot of configured credentials, not a key
518
+ // validation request. Never resolve or display credentials in this UI.
519
+ const choices = new Map<string, SettingItem>();
520
+ for (const model of ctx.modelRegistry.getAvailable()) {
521
+ const key = formatProgressModelKey(model);
522
+ if (parseSerializedProgressModel(key) !== key) continue;
523
+ choices.set(key, {
524
+ id: key,
525
+ label: key,
526
+ currentValue: key === currentValue ? "selected" : "",
527
+ description: `${key} — ${stripTerminalControls(model.name)}. Uses this provider's Pi credentials.`,
528
+ values: ["selected"],
529
+ });
530
+ }
531
+ const items: SettingItem[] = [{
532
+ id: "auto",
533
+ label: "Auto",
534
+ currentValue: currentValue === "auto" ? "selected" : "",
535
+ description: choices.size > 0
536
+ ? "Try fast Codex models, then Anthropic Haiku. Choose an openai/ model explicitly to use an OpenAI API key."
537
+ : "No models with configured credentials. Use /login, then reopen this picker.",
538
+ values: ["selected"],
539
+ }];
540
+ if (currentValue !== "auto" && !choices.has(currentValue)) {
541
+ items.push({
542
+ id: currentValue,
543
+ label: stripTerminalControls(currentValue),
544
+ currentValue: "unavailable",
545
+ description: "Current preference is not in Pi's available models. Use /login or choose another model.",
546
+ });
547
+ }
548
+ items.push(...[...choices.values()].sort((a, b) => a.id.localeCompare(b.id)));
549
+ const picker = new SettingsList(
550
+ items,
551
+ Math.min(items.length + 2, 14),
552
+ getSettingsListTheme(),
553
+ (id) => done(id),
554
+ () => done(),
555
+ { enableSearch: true },
556
+ );
557
+ picker.selectItem(currentValue);
558
+ return picker;
433
559
  }
434
560
 
435
561
  async function getModelAuth(
@@ -807,12 +933,19 @@ class FooterProgressEngine {
807
933
  private normalCheckpointTimer?: ReturnType<typeof setTimeout>;
808
934
  private normalCheckpointBurstStartedAt?: number;
809
935
 
810
- constructor(private readonly requestRender: () => void) {}
936
+ constructor(
937
+ private readonly requestRender: () => void,
938
+ private readonly createUsageRecorder: (ctx: ExtensionContext) => (usage: Usage) => void,
939
+ ) {}
811
940
 
812
941
  text(): string | null {
813
942
  return this.currentText;
814
943
  }
815
944
 
945
+ modelKey(): string {
946
+ return formatProgressModelKey(this.configuredModel);
947
+ }
948
+
816
949
  startSession(cwd: string): void {
817
950
  this.configuredModel = resolveProgressModelPreference(cwd);
818
951
  this.facts.resetConversation();
@@ -1058,6 +1191,7 @@ class FooterProgressEngine {
1058
1191
 
1059
1192
  abortController = new AbortController();
1060
1193
  this.abortController = abortController;
1194
+ const recordUsage = this.createUsageRecorder(ctx);
1061
1195
  const response = await complete(
1062
1196
  auth.model,
1063
1197
  { systemPrompt: checkpointSystemPrompt(job), messages: [prompt] },
@@ -1071,6 +1205,9 @@ class FooterProgressEngine {
1071
1205
  signal: abortController.signal,
1072
1206
  },
1073
1207
  );
1208
+ // A superseded/aborted request can still report billed usage. Account for
1209
+ // it even when its text must no longer be displayed.
1210
+ recordUsage(response.usage);
1074
1211
  if (!this.isCurrentJob(job) || response.stopReason !== "stop") return;
1075
1212
 
1076
1213
  const rawText = extractTextContent(response.content) ?? "";
@@ -1740,11 +1877,11 @@ function describeSegments(segments: readonly SegmentName[]): string {
1740
1877
  function describeStatusFilter(filter: StatusFilter): string {
1741
1878
  if (filter.mode === "only") {
1742
1879
  const shown = Array.from(filter.shown).sort();
1743
- return shown.length > 0 ? `showing only: ${shown.join(", ")}` : "showing none";
1880
+ return shown.length > 0 ? `showing only: ${shown.map(stripTerminalControls).join(", ")}` : "showing none";
1744
1881
  }
1745
1882
 
1746
1883
  const hidden = Array.from(filter.hidden).sort();
1747
- return hidden.length > 0 ? `showing all except: ${hidden.join(", ")}` : "showing all";
1884
+ return hidden.length > 0 ? `showing all except: ${hidden.map(stripTerminalControls).join(", ")}` : "showing all";
1748
1885
  }
1749
1886
 
1750
1887
  function readGlobalConfig(): GlobalBarConfig {
@@ -1754,6 +1891,8 @@ function readGlobalConfig(): GlobalBarConfig {
1754
1891
  return {
1755
1892
  statusFilter: statusFilter ? serializeStatusFilter(statusFilter) : undefined,
1756
1893
  segments: parseSerializedSegments(data.segments) ?? undefined,
1894
+ showProvider: typeof data.showProvider === "boolean" ? data.showProvider : undefined,
1895
+ progressModel: parseSerializedProgressModel(data.progressModel),
1757
1896
  };
1758
1897
  } catch {
1759
1898
  return {};
@@ -1786,6 +1925,76 @@ function writeGlobalSegments(segments: readonly SegmentName[]): void {
1786
1925
  writeGlobalConfig({ ...existing, segments: serializeSegments(segments) });
1787
1926
  }
1788
1927
 
1928
+ /** Pi replaces the entire argument prefix, not just its last word. */
1929
+ export function completeBarArguments(
1930
+ prefix: string,
1931
+ knownStatusKeys: readonly string[] = [],
1932
+ progressModelKeys: readonly string[] = [],
1933
+ ): AutocompleteItem[] | null {
1934
+ const words = prefix.trimStart().split(/\s+/);
1935
+ let fragment = words.at(-1) ?? "";
1936
+ let stem = prefix.slice(0, prefix.length - fragment.length);
1937
+ let candidates: readonly string[];
1938
+ let selected: string[] = [];
1939
+ let segmentValues = false;
1940
+ let badgeValues = false;
1941
+ let modelValues = false;
1942
+ const section = words[0];
1943
+ const directVisibility = section === "show" || section === "hide";
1944
+ const segmentSection = section === "segments" || section === "segment" || section === "footer";
1945
+ const statusSection = section === "badges" || section === "status" || section === "statuses";
1946
+
1947
+ if (words.length === 1) {
1948
+ candidates = [...BAR_COMMAND_DESCRIPTIONS.keys()];
1949
+ } else if (words.length === 2 && (segmentSection || statusSection)) {
1950
+ candidates = [...BAR_ACTION_DESCRIPTIONS.keys()];
1951
+ } else if (words.length === 2 && section === "provider") {
1952
+ candidates = ["show", "hide"];
1953
+ } else if (words.length === 2 && section === "progress-model") {
1954
+ modelValues = true;
1955
+ candidates = ["auto", ...[...new Set(progressModelKeys.filter(
1956
+ (key) => typeof key === "string" && key !== "auto" && parseSerializedProgressModel(key) === key,
1957
+ ))].sort()];
1958
+ } else if (directVisibility || ((segmentSection || statusSection) && ["only", "show", "hide"].includes(words[1]))) {
1959
+ const comma = fragment.lastIndexOf(",");
1960
+ const priorValues = fragment.slice(0, comma + 1);
1961
+ stem += priorValues;
1962
+ fragment = fragment.slice(comma + 1);
1963
+ selected = [...words.slice(directVisibility ? 1 : 2, -1), priorValues].join(" ").split(/[\s,]+/).filter(Boolean);
1964
+ segmentValues = directVisibility || segmentSection;
1965
+ badgeValues = !segmentValues;
1966
+ // Keys containing delimiters/controls are still configurable in the UI,
1967
+ // but cannot be represented safely by the existing command grammar.
1968
+ if (directVisibility && selected.some((value) => value.toLowerCase() === "all")) return null;
1969
+ candidates = segmentValues
1970
+ ? [...(directVisibility && selected.length === 0 ? ["all"] : []), ...ALL_SEGMENTS]
1971
+ : [...new Set(knownStatusKeys)].filter(
1972
+ (key) => typeof key === "string" && key.length > 0 && !/[\s,\x00-\x1f\x7f-\x9f]/u.test(key),
1973
+ ).sort();
1974
+ } else {
1975
+ return null;
1976
+ }
1977
+
1978
+ const description = (value: string): string | undefined => {
1979
+ if (words.length === 1) return BAR_COMMAND_DESCRIPTIONS.get(value);
1980
+ if (segmentValues) return value === "all" ? "All footer items" : isSegmentName(value) ? SEGMENT_LABELS[value] : undefined;
1981
+ if (badgeValues) return "Extension badge";
1982
+ if (modelValues) return value === "auto" ? "Automatic fast-model selection" : "Progress model";
1983
+ if (section === "provider") return `${value === "show" ? "Show" : "Hide"} provider prefix`;
1984
+ return BAR_ACTION_DESCRIPTIONS.get(value);
1985
+ };
1986
+ const normalize = (value: string) => segmentValues || modelValues ? value.toLowerCase() : value;
1987
+ const used = new Set(selected.map(normalize));
1988
+ const query = normalize(fragment);
1989
+ const matches = candidates
1990
+ .filter((value) => {
1991
+ const normalized = normalize(value);
1992
+ return (modelValues ? normalized.includes(query) : normalized.startsWith(query)) && !used.has(normalized);
1993
+ })
1994
+ .map((value) => ({ value: `${stem}${value}`, label: value, description: description(value) }));
1995
+ return matches.length > 0 ? matches : null;
1996
+ }
1997
+
1789
1998
  function getKnownStatusKeys(filter: StatusFilter, seenStatusKeys: Set<string>): string[] {
1790
1999
  const keys = new Set(seenStatusKeys);
1791
2000
  if (filter.mode === "only") {
@@ -1800,12 +2009,36 @@ export default function (pi: ExtensionAPI) {
1800
2009
  let requestRender: (() => void) | undefined;
1801
2010
  let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
1802
2011
  let visibleSegments: SegmentName[] = readGlobalSegments() ?? DEFAULT_SEGMENTS;
2012
+ let showProvider = readGlobalConfig().showProvider ?? false;
1803
2013
  const seenStatusKeys = new Set<string>();
1804
2014
  let currentStatuses: ReadonlyMap<string, string> = new Map();
2015
+ let completionModelRegistry: ExtensionContext["modelRegistry"] | undefined;
1805
2016
  const statusDescription = (key: string) =>
1806
2017
  stripTerminalControls(currentStatuses.get(key) ?? "") || "No current status text";
1807
2018
  const refresh = () => requestRender?.();
1808
- const progress = new FooterProgressEngine(refresh);
2019
+ const usageTracker = new FooterUsageTracker();
2020
+ const syncUsage = (ctx: ExtensionContext, restoreBranch = false) => {
2021
+ usageTracker.syncEntries(ctx.sessionManager.getEntries());
2022
+ if (restoreBranch) usageTracker.restoreBranch(ctx.sessionManager.getBranch());
2023
+ };
2024
+ let sessionEpoch = 0;
2025
+ let sessionOpen = false;
2026
+ const progress = new FooterProgressEngine(refresh, (ctx) => {
2027
+ const epoch = sessionEpoch;
2028
+ return (usage) => {
2029
+ // Never append a late response into a replacement/reloaded session.
2030
+ if (!sessionOpen || epoch !== sessionEpoch) return;
2031
+ const totals = totalsFromUsage(usage);
2032
+ if (!hasUsage(totals)) return;
2033
+ try {
2034
+ pi.appendEntry(PROGRESS_USAGE_ENTRY_TYPE, totals);
2035
+ syncUsage(ctx);
2036
+ refresh();
2037
+ } catch {
2038
+ // Usage persistence is best effort; do not discard a valid progress update.
2039
+ }
2040
+ };
2041
+ });
1809
2042
  const restoreStatusFilter = (ctx: ExtensionContext) => {
1810
2043
  let restoredFilter = readGlobalStatusFilter();
1811
2044
  if (!restoredFilter) {
@@ -1821,6 +2054,29 @@ export default function (pi: ExtensionAPI) {
1821
2054
  writeGlobalStatusFilter(statusFilter);
1822
2055
  refresh();
1823
2056
  };
2057
+ const setShowProvider = (value: boolean) => {
2058
+ showProvider = value;
2059
+ writeGlobalConfig({ ...readGlobalConfig(), showProvider });
2060
+ refresh();
2061
+ };
2062
+ const setProgressModel = (value: string, ctx: ExtensionContext): boolean => {
2063
+ if (process.env.PI_BAR_PROGRESS_MODEL) {
2064
+ ctx.ui.notify("Progress model is controlled by PI_BAR_PROGRESS_MODEL. Unset it and restart Pi to choose here.", "warning");
2065
+ return false;
2066
+ }
2067
+ const model = parseSerializedProgressModel(value);
2068
+ if (!model) return false;
2069
+ try {
2070
+ writeGlobalConfig({ ...readGlobalConfig(), progressModel: model });
2071
+ } catch {
2072
+ ctx.ui.notify("Could not save the progress model. Check pi-bar config file permissions; selection unchanged.", "error");
2073
+ return false;
2074
+ }
2075
+ // Reset/cancel old-provider jobs, but do not enable a hidden segment.
2076
+ if (visibleSegments.includes("progress") && model !== progress.modelKey()) progress.startSession(ctx.cwd);
2077
+ refresh();
2078
+ return true;
2079
+ };
1824
2080
  const setVisibleSegments = (segments: readonly SegmentName[], ctx?: ExtensionContext) => {
1825
2081
  const previousProgressVisible = visibleSegments.includes("progress");
1826
2082
  visibleSegments = serializeSegments(segments);
@@ -1830,228 +2086,167 @@ export default function (pi: ExtensionAPI) {
1830
2086
  if (!previousProgressVisible && nextProgressVisible && ctx) progress.startSession(ctx.cwd);
1831
2087
  refresh();
1832
2088
  };
1833
- const openSegmentConfigurator = async (ctx: ExtensionContext) => {
1834
- await ctx.ui.custom((tui, theme, _kb, done) => {
1835
- const knownStatusKeys = getKnownStatusKeys(statusFilter, seenStatusKeys);
1836
- const segmentVisibility = new Map(
1837
- ALL_SEGMENTS.map(
1838
- (segment): [SegmentName, boolean] => [segment, visibleSegments.includes(segment)],
1839
- ),
1840
- );
1841
- let futureShown = statusFilter.mode === "all";
1842
- const statusVisibility = new Map(
1843
- knownStatusKeys.map((key): [string, boolean] => [
1844
- key,
1845
- shouldShowStatus(key, statusFilter),
1846
- ]),
1847
- );
1848
- const persistSegmentsFromVisibility = () => {
1849
- setVisibleSegments(
1850
- ALL_SEGMENTS.filter((segment) => segmentVisibility.get(segment)),
1851
- ctx,
1852
- );
1853
- };
1854
- const persistStatusesFromVisibility = () => {
1855
- if (futureShown) {
1856
- statusFilter = {
1857
- mode: "all",
1858
- hidden: new Set(
1859
- knownStatusKeys.filter((key) => !statusVisibility.get(key)),
1860
- ),
1861
- };
1862
- } else {
1863
- statusFilter = {
1864
- mode: "only",
1865
- shown: new Set(
1866
- knownStatusKeys.filter((key) => statusVisibility.get(key)),
1867
- ),
1868
- };
1869
- }
1870
- persistStatusFilter();
1871
- };
1872
-
1873
- const segmentItems: SettingItem[] = ALL_SEGMENTS.map((segment): SettingItem => ({
1874
- id: `segment:${segment}`,
1875
- label: SEGMENT_LABELS[segment],
1876
- description: "Footer segment visibility",
1877
- currentValue: segmentVisibility.get(segment) ? "shown" : "hidden",
2089
+ const setBadgeVisibility = (key: string, shown: boolean) => {
2090
+ if (statusFilter.mode === "all") {
2091
+ if (shown) statusFilter.hidden.delete(key);
2092
+ else statusFilter.hidden.add(key);
2093
+ } else {
2094
+ if (shown) statusFilter.shown.add(key);
2095
+ else statusFilter.shown.delete(key);
2096
+ }
2097
+ };
2098
+ const badgeSummary = () => {
2099
+ const keys = getKnownStatusKeys(statusFilter, seenStatusKeys);
2100
+ const enabled = keys.filter((key) => shouldShowStatus(key, statusFilter)).length;
2101
+ return keys.length > 0 ? `${enabled}/${keys.length} enabled` : "No badges yet";
2102
+ };
2103
+ const savedSettingsTheme = (exit: "close" | "go back") => {
2104
+ const theme = getSettingsListTheme();
2105
+ return {
2106
+ ...theme,
2107
+ // These toggles save immediately; Esc is not an undo operation.
2108
+ hint: (text: string) => theme.hint(text.replace("Esc to cancel", `Esc to ${exit}`)),
2109
+ };
2110
+ };
2111
+ const createBadgeSettings = (onClose: () => void, exit: "close" | "go back") => {
2112
+ const knownKeys = getKnownStatusKeys(statusFilter, seenStatusKeys);
2113
+ const theme = savedSettingsTheme(exit);
2114
+ const items: SettingItem[] = [
2115
+ {
2116
+ id: "default:future",
2117
+ label: "New badges",
2118
+ description: "Default for badges discovered later. Existing badge choices stay unchanged.",
2119
+ currentValue: statusFilter.mode === "all" ? "shown" : "hidden",
1878
2120
  values: ["shown", "hidden"],
1879
- }));
1880
- const statusItems: SettingItem[] = knownStatusKeys.length > 0
1881
- ? [
1882
- {
1883
- id: "status:__future",
1884
- label: "New extension statuses",
1885
- description: "Default visibility for status keys discovered later",
1886
- currentValue: futureShown ? "shown" : "hidden",
1887
- values: ["shown", "hidden"],
1888
- },
1889
- ...knownStatusKeys.map((key): SettingItem => ({
1890
- id: `status:${key}`,
1891
- label: `Status: ${stripTerminalControls(key)}`,
1892
- description: statusDescription(key),
1893
- currentValue: statusVisibility.get(key) ? "shown" : "hidden",
1894
- values: ["shown", "hidden"],
1895
- })),
1896
- ]
1897
- : [];
1898
- const items: SettingItem[] = [...segmentItems, ...statusItems];
1899
-
2121
+ },
2122
+ ...knownKeys.map((key): SettingItem => ({
2123
+ id: `badge:${key}`,
2124
+ label: `Badge: ${stripTerminalControls(key)}`,
2125
+ description: statusDescription(key),
2126
+ currentValue: shouldShowStatus(key, statusFilter) ? "shown" : "hidden",
2127
+ values: ["shown", "hidden"],
2128
+ })),
2129
+ ];
2130
+ const settings = new SettingsList(items, Math.min(items.length + 2, 15), theme, (id, value) => {
2131
+ if (id === "default:future") {
2132
+ const keys = getKnownStatusKeys(statusFilter, seenStatusKeys);
2133
+ statusFilter = value === "shown"
2134
+ ? { mode: "all", hidden: new Set(keys.filter((key) => !shouldShowStatus(key, statusFilter))) }
2135
+ : { mode: "only", shown: new Set(keys.filter((key) => shouldShowStatus(key, statusFilter))) };
2136
+ } else {
2137
+ setBadgeVisibility(id.slice("badge:".length), value === "shown");
2138
+ }
2139
+ persistStatusFilter();
2140
+ }, onClose, { enableSearch: true });
2141
+ return {
2142
+ render(width: number) {
2143
+ return [
2144
+ theme.label("Extension badges", true),
2145
+ theme.hint(knownKeys.length > 0
2146
+ ? "Choose badges by key; select a row to preview its text."
2147
+ : "No badges yet. Extensions publish them while you work."),
2148
+ theme.hint("Changes save immediately."),
2149
+ ...(!visibleSegments.includes("extensions")
2150
+ ? [theme.hint("All badges are hidden. Use /bar show extensions to display them.")]
2151
+ : []),
2152
+ "",
2153
+ ...settings.render(Math.max(8, width)),
2154
+ ].map((line) => truncateToWidth(line, Math.max(0, width)));
2155
+ },
2156
+ invalidate() { settings.invalidate(); },
2157
+ handleInput(data: string) { settings.handleInput(data); },
2158
+ };
2159
+ };
2160
+ const openSettings = async (ctx: ExtensionContext) => {
2161
+ if (ctx.mode !== "tui") {
2162
+ ctx.ui.notify("/bar settings requires TUI mode", "warning");
2163
+ return;
2164
+ }
2165
+ await ctx.ui.custom((tui, theme, _kb, done) => {
2166
+ const items = ALL_SEGMENTS.flatMap((segment): SettingItem[] => {
2167
+ const group: SettingItem[] = [{
2168
+ id: `segment:${segment}`,
2169
+ label: SEGMENT_LABELS[segment],
2170
+ description: SEGMENT_DESCRIPTIONS[segment],
2171
+ currentValue: visibleSegments.includes(segment) ? "shown" : "hidden",
2172
+ values: ["shown", "hidden"],
2173
+ }];
2174
+ if (segment === "model") group.push({
2175
+ id: "show-provider",
2176
+ label: "Show provider",
2177
+ description: "Prefix the model name with its provider. Only visible when Model is shown.",
2178
+ currentValue: showProvider ? "shown" : "hidden",
2179
+ values: ["shown", "hidden"],
2180
+ });
2181
+ if (segment === "progress") group.push({
2182
+ id: "progress-model",
2183
+ label: "Progress model",
2184
+ description: process.env.PI_BAR_PROGRESS_MODEL
2185
+ ? "Controlled by PI_BAR_PROGRESS_MODEL (read-only). Unset it and restart Pi to choose here."
2186
+ : "Choose Auto or search models with configured Pi credentials. Changes only progress updates, not the chat model.",
2187
+ currentValue: formatProgressModelKey(resolveProgressModelPreference(ctx.cwd)),
2188
+ submenu: process.env.PI_BAR_PROGRESS_MODEL
2189
+ ? undefined
2190
+ : (currentValue, close) => createProgressModelPicker(ctx, currentValue, close),
2191
+ });
2192
+ if (segment === "extensions") group.push({
2193
+ id: "badge-settings",
2194
+ label: "Choose badges",
2195
+ description: "Choose individual badges from other extensions. They display only when Extension badges is shown.",
2196
+ currentValue: badgeSummary(),
2197
+ submenu: (_currentValue, close) => createBadgeSettings(() => close(badgeSummary()), "go back"),
2198
+ });
2199
+ return group;
2200
+ });
1900
2201
  const container = new Container();
1901
- container.addChild(
1902
- new (class {
1903
- render(_width: number) {
1904
- return [
1905
- theme.fg("accent", theme.bold("pi-bar visibility")),
1906
- theme.fg(
1907
- "dim",
1908
- knownStatusKeys.length > 0
1909
- ? "Footer segments + extension statuses · Enter/Space toggles · Esc closes"
1910
- : "Footer segments · no extension statuses seen yet · Esc closes",
1911
- ),
1912
- "",
1913
- ];
1914
- }
1915
- invalidate() {}
1916
- })(),
1917
- );
1918
-
1919
- const settingsList = new SettingsList(
1920
- items,
1921
- Math.min(items.length + 2, 18),
1922
- getSettingsListTheme(),
1923
- (id, newValue) => {
1924
- if (id.startsWith("segment:")) {
1925
- const segment = id.slice("segment:".length);
1926
- if (!isSegmentName(segment)) return;
1927
- segmentVisibility.set(segment, newValue === "shown");
1928
- persistSegmentsFromVisibility();
1929
- return;
1930
- }
1931
-
1932
- if (id === "status:__future") {
1933
- futureShown = newValue === "shown";
1934
- persistStatusesFromVisibility();
1935
- return;
1936
- }
1937
-
1938
- if (id.startsWith("status:")) {
1939
- statusVisibility.set(id.slice("status:".length), newValue === "shown");
1940
- persistStatusesFromVisibility();
1941
- }
1942
- },
1943
- () => done(undefined),
1944
- { enableSearch: true },
1945
- );
1946
-
1947
- container.addChild(settingsList);
1948
-
2202
+ container.addChild(new (class {
2203
+ render(_width: number) {
2204
+ return [
2205
+ theme.fg("accent", theme.bold("pi-bar settings")),
2206
+ theme.fg("dim", "Choose what appears in your footer. Changes save immediately."),
2207
+ "",
2208
+ ];
2209
+ }
2210
+ invalidate() {}
2211
+ })());
2212
+ const settings = new SettingsList(items, Math.min(items.length + 2, 18), savedSettingsTheme("close"), (id, value) => {
2213
+ if (id === "progress-model") {
2214
+ setProgressModel(value, ctx);
2215
+ settings.updateValue(id, formatProgressModelKey(resolveProgressModelPreference(ctx.cwd)));
2216
+ } else if (id === "show-provider") {
2217
+ setShowProvider(value === "shown");
2218
+ } else if (id.startsWith("segment:")) {
2219
+ const segment = id.slice("segment:".length);
2220
+ if (!isSegmentName(segment)) return;
2221
+ setVisibleSegments(value === "shown"
2222
+ ? [...visibleSegments, segment]
2223
+ : visibleSegments.filter((item) => item !== segment), ctx);
2224
+ }
2225
+ }, () => done(undefined), { enableSearch: true });
2226
+ container.addChild(settings);
1949
2227
  return {
1950
2228
  render(width: number) {
1951
- return container.render(width);
1952
- },
1953
- invalidate() {
1954
- container.invalidate();
2229
+ return container.render(Math.max(8, width)).map((line) => truncateToWidth(line, Math.max(0, width)));
1955
2230
  },
2231
+ invalidate() { container.invalidate(); },
1956
2232
  handleInput(data: string) {
1957
- settingsList.handleInput?.(data);
2233
+ settings.handleInput(data);
1958
2234
  tui.requestRender();
1959
2235
  },
1960
2236
  };
1961
2237
  });
1962
2238
  };
1963
- const openStatusConfigurator = async (ctx: ExtensionContext) => {
1964
- const knownStatusKeys = getKnownStatusKeys(statusFilter, seenStatusKeys);
1965
- if (knownStatusKeys.length === 0) {
1966
- ctx.ui.notify(
1967
- "No extension statuses seen yet. Open /bar after another extension calls ctx.ui.setStatus().",
1968
- "info",
1969
- );
2239
+ const openBadges = async (ctx: ExtensionContext) => {
2240
+ if (ctx.mode !== "tui") {
2241
+ ctx.ui.notify("/bar badges requires TUI mode", "warning");
1970
2242
  return;
1971
2243
  }
1972
-
1973
- await ctx.ui.custom((tui, theme, _kb, done) => {
1974
- let futureShown = statusFilter.mode === "all";
1975
- const statusVisibility = new Map(
1976
- knownStatusKeys.map((key) => [key, shouldShowStatus(key, statusFilter)]),
1977
- );
1978
- const persistFromVisibility = () => {
1979
- if (futureShown) {
1980
- statusFilter = {
1981
- mode: "all",
1982
- hidden: new Set(
1983
- knownStatusKeys.filter((key) => !statusVisibility.get(key)),
1984
- ),
1985
- };
1986
- } else {
1987
- statusFilter = {
1988
- mode: "only",
1989
- shown: new Set(
1990
- knownStatusKeys.filter((key) => statusVisibility.get(key)),
1991
- ),
1992
- };
1993
- }
1994
- persistStatusFilter();
1995
- };
1996
-
1997
- const items: SettingItem[] = [
1998
- {
1999
- id: "__future",
2000
- label: "New statuses",
2001
- description: "Default visibility for status keys discovered later",
2002
- currentValue: futureShown ? "shown" : "hidden",
2003
- values: ["shown", "hidden"],
2004
- },
2005
- ...knownStatusKeys.map((key): SettingItem => ({
2006
- id: key,
2007
- label: stripTerminalControls(key),
2008
- description: statusDescription(key),
2009
- currentValue: statusVisibility.get(key) ? "shown" : "hidden",
2010
- values: ["shown", "hidden"],
2011
- })),
2012
- ];
2013
-
2014
- const container = new Container();
2015
- container.addChild(
2016
- new (class {
2017
- render(_width: number) {
2018
- return [
2019
- theme.fg("accent", theme.bold("pi-bar status visibility")),
2020
- theme.fg("dim", "Enter/Space toggles · Esc closes"),
2021
- "",
2022
- ];
2023
- }
2024
- invalidate() {}
2025
- })(),
2026
- );
2027
-
2028
- const settingsList = new SettingsList(
2029
- items,
2030
- Math.min(items.length + 2, 15),
2031
- getSettingsListTheme(),
2032
- (id, newValue) => {
2033
- if (id === "__future") {
2034
- futureShown = newValue === "shown";
2035
- } else {
2036
- statusVisibility.set(id, newValue === "shown");
2037
- }
2038
- persistFromVisibility();
2039
- },
2040
- () => done(undefined),
2041
- { enableSearch: true },
2042
- );
2043
-
2044
- container.addChild(settingsList);
2045
-
2244
+ await ctx.ui.custom((tui, _theme, _kb, done) => {
2245
+ const settings = createBadgeSettings(() => done(undefined), "close");
2046
2246
  return {
2047
- render(width: number) {
2048
- return container.render(width);
2049
- },
2050
- invalidate() {
2051
- container.invalidate();
2052
- },
2247
+ ...settings,
2053
2248
  handleInput(data: string) {
2054
- settingsList.handleInput?.(data);
2249
+ settings.handleInput(data);
2055
2250
  tui.requestRender();
2056
2251
  },
2057
2252
  };
@@ -2059,23 +2254,105 @@ export default function (pi: ExtensionAPI) {
2059
2254
  };
2060
2255
 
2061
2256
  pi.registerCommand("bar", {
2062
- description: "Configure pi-bar footer visibility",
2257
+ description: "Open footer settings; choose what appears in the bar",
2258
+ getArgumentCompletions: (prefix) => {
2259
+ let modelKeys: string[] = [];
2260
+ if (/^\s*progress-model\s+\S*$/.test(prefix)) {
2261
+ if (!sessionOpen || process.env.PI_BAR_PROGRESS_MODEL) return null;
2262
+ // Read current local availability only for model arguments, never
2263
+ // refresh catalogs or resolve credentials while the user types.
2264
+ modelKeys = completionModelRegistry?.getAvailable().map(formatProgressModelKey) ?? [];
2265
+ }
2266
+ return completeBarArguments(prefix, getKnownStatusKeys(statusFilter, seenStatusKeys), modelKeys);
2267
+ },
2063
2268
  handler: async (args, ctx) => {
2064
- const [section, action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
2065
- if (!section || section === "config" || section === "configure" || section === "edit") {
2066
- await openSegmentConfigurator(ctx);
2269
+ const words = args.trim().split(/\s+/).filter(Boolean);
2270
+ if (words[0] === "show" || words[0] === "hide") {
2271
+ const names = splitStatusKeys(words.slice(1).join(" ")).map((name) => name.toLowerCase());
2272
+ if (names.includes("all")) {
2273
+ if (names.length !== 1) {
2274
+ ctx.ui.notify("Use all by itself: /bar show all or /bar hide all.", "warning");
2275
+ return;
2276
+ }
2277
+ words.splice(0, words.length, "segments", words[0] === "show" ? "all" : "none");
2278
+ } else {
2279
+ const unknown = names.filter((name) => !isSegmentName(name));
2280
+ if (unknown.length > 0) {
2281
+ ctx.ui.notify(`Unknown footer items: ${unknown.map(stripTerminalControls).join(", ")}. Use Tab to choose items.`, "warning");
2282
+ return;
2283
+ }
2284
+ words.unshift("segments");
2285
+ }
2286
+ }
2287
+ if (words[0] === "badges") {
2288
+ if (["show", "hide", "only"].includes(words[1]) && splitStatusKeys(words.slice(2).join(" ")).length === 0) {
2289
+ ctx.ui.notify("Choose badge keys with Tab, or open /bar badges to choose them interactively.", "warning");
2290
+ return;
2291
+ }
2292
+ words[0] = "status";
2293
+ }
2294
+ const [section, action, ...rest] = words;
2295
+ if (!section || section === "settings" || section === "config" || section === "configure" || section === "edit") {
2296
+ await openSettings(ctx);
2067
2297
  return;
2068
2298
  }
2069
2299
  if (section === "list" || section === "ls") {
2070
2300
  ctx.ui.notify(`pi-bar footer: ${describeSegments(visibleSegments)}`, "info");
2071
2301
  return;
2072
2302
  }
2303
+ if (section === "help") {
2304
+ ctx.ui.notify([
2305
+ "Open /bar for all footer settings. Changes save immediately.",
2306
+ "",
2307
+ ...Array.from(BAR_COMMAND_DESCRIPTIONS, ([name, description]) => `/bar ${name} — ${description}`),
2308
+ "",
2309
+ "Examples: /bar show cost tokens · /bar hide progress · /bar badges hide mcp",
2310
+ "Use /bar show all or /bar hide all to change every footer item.",
2311
+ `Footer items: ${ALL_SEGMENTS.join(", ")}`,
2312
+ `Current configuration: ${describeSegments(visibleSegments)}`,
2313
+ "Enabled items may wait for data or yield under width pressure.",
2314
+ "Old commands still work: /bar config, /bar segments, /bar status, /bar list.",
2315
+ ].join("\n"), "info");
2316
+ return;
2317
+ }
2318
+
2319
+ if (section === "progress-model") {
2320
+ if (!action) {
2321
+ const value = formatProgressModelKey(resolveProgressModelPreference(ctx.cwd));
2322
+ const override = process.env.PI_BAR_PROGRESS_MODEL ? " (PI_BAR_PROGRESS_MODEL, read-only)" : "";
2323
+ ctx.ui.notify(`pi-bar progress model: ${value}${override}`, "info");
2324
+ return;
2325
+ }
2326
+ const model = parseSerializedProgressModel(action);
2327
+ if (rest.length > 0 || !model) {
2328
+ ctx.ui.notify("Usage: /bar progress-model [auto|provider/model]", "warning");
2329
+ return;
2330
+ }
2331
+ if (!process.env.PI_BAR_PROGRESS_MODEL && model !== "auto" && !ctx.modelRegistry.getAvailable().some(
2332
+ (available) => formatProgressModelKey(available) === model,
2333
+ )) {
2334
+ ctx.ui.notify("Progress model unavailable. Use /login or choose a configured model with Tab.", "warning");
2335
+ return;
2336
+ }
2337
+ if (setProgressModel(model, ctx)) ctx.ui.notify(`pi-bar progress model: ${model}`, "info");
2338
+ return;
2339
+ }
2340
+
2341
+ if (section === "provider") {
2342
+ if (rest.length > 0 || (action !== undefined && action !== "show" && action !== "hide")) {
2343
+ ctx.ui.notify("Usage: /bar provider [show|hide]", "warning");
2344
+ return;
2345
+ }
2346
+ if (action) setShowProvider(action === "show");
2347
+ ctx.ui.notify(`pi-bar provider prefix: ${showProvider ? "shown" : "hidden"}`, "info");
2348
+ return;
2349
+ }
2073
2350
 
2074
2351
  if (section === "segment" || section === "segments" || section === "footer") {
2075
2352
  const segments = splitSegmentNames(rest.join(" "));
2076
2353
  if ((action === "only" || action === "show" || action === "hide") && segments.length === 0) {
2077
2354
  ctx.ui.notify(
2078
- `Segments: ${ALL_SEGMENTS.join(", ")}`,
2355
+ `Footer items: ${ALL_SEGMENTS.join(", ")}. Use Tab to choose.`,
2079
2356
  "warning",
2080
2357
  );
2081
2358
  return;
@@ -2083,10 +2360,11 @@ export default function (pi: ExtensionAPI) {
2083
2360
 
2084
2361
  switch (action) {
2085
2362
  case undefined:
2363
+ case "settings":
2086
2364
  case "config":
2087
2365
  case "configure":
2088
2366
  case "edit":
2089
- await openSegmentConfigurator(ctx);
2367
+ await openSettings(ctx);
2090
2368
  return;
2091
2369
  case "list":
2092
2370
  case "ls":
@@ -2112,7 +2390,7 @@ export default function (pi: ExtensionAPI) {
2112
2390
  break;
2113
2391
  default:
2114
2392
  ctx.ui.notify(
2115
- "Usage: /bar [config] or /bar segments [list|all|none|only <segments>|show <segments>|hide <segments>]",
2393
+ "Use /bar show <items> or /bar hide <items>. Open /bar for all settings.",
2116
2394
  "warning",
2117
2395
  );
2118
2396
  return;
@@ -2123,7 +2401,7 @@ export default function (pi: ExtensionAPI) {
2123
2401
  }
2124
2402
 
2125
2403
  if ((section === "status" || section === "statuses") && !action) {
2126
- await openStatusConfigurator(ctx);
2404
+ await openBadges(ctx);
2127
2405
  return;
2128
2406
  }
2129
2407
 
@@ -2131,16 +2409,17 @@ export default function (pi: ExtensionAPI) {
2131
2409
  const keys = splitStatusKeys(rest.join(" "));
2132
2410
 
2133
2411
  switch (action) {
2412
+ case "settings":
2134
2413
  case "config":
2135
2414
  case "configure":
2136
2415
  case "edit":
2137
- await openStatusConfigurator(ctx);
2416
+ await openBadges(ctx);
2138
2417
  return;
2139
2418
  case "list":
2140
2419
  case "ls": {
2141
2420
  const known = getKnownStatusKeys(statusFilter, seenStatusKeys);
2142
2421
  ctx.ui.notify(
2143
- `pi-bar statuses: ${describeStatusFilter(statusFilter)}${known.length > 0 ? `; known: ${known.join(", ")}` : "; known: none yet"}`,
2422
+ `pi-bar badges: ${describeStatusFilter(statusFilter)}${known.length > 0 ? `; known: ${known.map(stripTerminalControls).join(", ")}` : "; known: none yet"}`,
2144
2423
  "info",
2145
2424
  );
2146
2425
  return;
@@ -2155,34 +2434,24 @@ export default function (pi: ExtensionAPI) {
2155
2434
  statusFilter = { mode: "only", shown: new Set(keys) };
2156
2435
  break;
2157
2436
  case "hide":
2158
- if (statusFilter.mode === "only") {
2159
- for (const key of keys) statusFilter.shown.delete(key);
2160
- } else {
2161
- for (const key of keys) statusFilter.hidden.add(key);
2162
- }
2163
- break;
2164
2437
  case "show":
2165
- if (statusFilter.mode === "only") {
2166
- for (const key of keys) statusFilter.shown.add(key);
2167
- } else {
2168
- for (const key of keys) statusFilter.hidden.delete(key);
2169
- }
2438
+ for (const key of keys) setBadgeVisibility(key, action === "show");
2170
2439
  break;
2171
2440
  default:
2172
2441
  ctx.ui.notify(
2173
- "Usage: /bar status [list|all|none|only <keys>|show <keys>|hide <keys>]",
2442
+ "Open /bar badges to choose badges, or use /bar badges show|hide <keys>.",
2174
2443
  "warning",
2175
2444
  );
2176
2445
  return;
2177
2446
  }
2178
2447
 
2179
2448
  persistStatusFilter();
2180
- ctx.ui.notify(`pi-bar statuses: ${describeStatusFilter(statusFilter)}`, "info");
2449
+ ctx.ui.notify(`pi-bar badges: ${describeStatusFilter(statusFilter)}`, "info");
2181
2450
  return;
2182
2451
  }
2183
2452
 
2184
2453
  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>]",
2454
+ "Unknown /bar command. Open /bar for settings or /bar help for examples.",
2186
2455
  "warning",
2187
2456
  );
2188
2457
  },
@@ -2190,7 +2459,11 @@ export default function (pi: ExtensionAPI) {
2190
2459
 
2191
2460
  pi.on("model_select", async () => refresh());
2192
2461
  pi.on("thinking_level_select", async () => refresh());
2193
- pi.on("turn_end", async () => refresh());
2462
+ // message_end runs before persistence and allows later handlers to replace
2463
+ // usage. Read finalized entries at the synchronized lifecycle boundaries.
2464
+ pi.on("turn_end", async (_event, ctx) => { syncUsage(ctx); refresh(); });
2465
+ pi.on("agent_end", async (_event, ctx) => { syncUsage(ctx); refresh(); });
2466
+ pi.on("session_compact", async (_event, ctx) => { syncUsage(ctx); refresh(); });
2194
2467
  pi.on("before_agent_start", async (event, ctx) => {
2195
2468
  if (visibleSegments.includes("progress")) progress.recordUserMessage(ctx, event.prompt);
2196
2469
  });
@@ -2198,7 +2471,9 @@ export default function (pi: ExtensionAPI) {
2198
2471
  if (visibleSegments.includes("progress")) progress.recordAssistantUpdate(ctx, event.message);
2199
2472
  });
2200
2473
  pi.on("tool_call", async (event, ctx) => {
2474
+ syncUsage(ctx);
2201
2475
  if (visibleSegments.includes("progress")) progress.recordToolCall(ctx, event);
2476
+ refresh();
2202
2477
  });
2203
2478
  pi.on("tool_result", async (event, ctx) => {
2204
2479
  if (visibleSegments.includes("progress")) progress.recordToolResult(ctx, event);
@@ -2214,11 +2489,21 @@ export default function (pi: ExtensionAPI) {
2214
2489
  if (visibleSegments.includes("progress")) progress.startSession(ctx.cwd);
2215
2490
  else progress.shutdown();
2216
2491
  restoreStatusFilter(ctx);
2492
+ showProvider = readGlobalConfig().showProvider ?? false;
2493
+ syncUsage(ctx, true);
2217
2494
  refresh();
2218
2495
  });
2219
2496
 
2220
2497
  pi.on("session_start", async (_event, ctx) => {
2498
+ sessionEpoch++;
2499
+ sessionOpen = true;
2500
+ completionModelRegistry = ctx.modelRegistry;
2501
+ usageTracker.reset();
2502
+ syncUsage(ctx, true);
2503
+ currentStatuses = new Map();
2504
+ seenStatusKeys.clear();
2221
2505
  visibleSegments = readGlobalSegments() ?? DEFAULT_SEGMENTS;
2506
+ showProvider = readGlobalConfig().showProvider ?? false;
2222
2507
  if (visibleSegments.includes("progress")) progress.startSession(ctx.cwd);
2223
2508
  else progress.shutdown();
2224
2509
  restoreStatusFilter(ctx);
@@ -2237,7 +2522,9 @@ export default function (pi: ExtensionAPI) {
2237
2522
  },
2238
2523
  invalidate() {},
2239
2524
  render(width: number): string[] {
2240
- const modelName = formatModelName(ctx.model?.id);
2525
+ const modelName = formatModelName(ctx.model, showProvider);
2526
+ const metrics = usageTracker.snapshot();
2527
+ const reportedUsage = hasUsage(metrics);
2241
2528
  const thinkingLevel = String(pi.getThinkingLevel());
2242
2529
  currentStatuses = footerData?.getExtensionStatuses?.() ?? new Map();
2243
2530
  const extensionStatusParts = formatExtensionStatuses(
@@ -2265,7 +2552,10 @@ export default function (pi: ExtensionAPI) {
2265
2552
  : "—";
2266
2553
  const shortThinking = thinkingLevel === "medium" ? "med" : thinkingLevel === "minimal" ? "min" : thinkingLevel;
2267
2554
  const renderers: Record<SegmentName, Omit<FooterSegment, "name">> = {
2268
- model: { text: theme.fg("accent", stripTerminalControls(modelName)) },
2555
+ model: {
2556
+ text: theme.fg("accent", modelName),
2557
+ alternatives: showProvider ? [theme.fg("accent", formatModelName(ctx.model))] : [],
2558
+ },
2269
2559
  thinking: {
2270
2560
  text: theme.fg(thinkingColor(thinkingLevel), `think:${thinkingLevel}`),
2271
2561
  alternatives: [theme.fg(thinkingColor(thinkingLevel), `think:${shortThinking}`)],
@@ -2274,6 +2564,11 @@ export default function (pi: ExtensionAPI) {
2274
2564
  text: theme.fg(contextSegmentColor, contextText),
2275
2565
  alternatives: [theme.fg(contextSegmentColor, contextCompact)],
2276
2566
  },
2567
+ cache_hit_ratio: {
2568
+ text: metrics.cacheHitRate === null ? "" : theme.fg(cacheHitColor(metrics.cacheHitRate), `CH:${metrics.cacheHitRate.toFixed(1).replace(/\.0$/, "")}%`),
2569
+ },
2570
+ cost: { text: reportedUsage ? theme.fg("muted", `≈$${metrics.cost.toFixed(3)}`) : "" },
2571
+ tokens: { text: reportedUsage ? theme.fg("muted", `↑${formatTokens(metrics.input)} ↓${formatTokens(metrics.output)}`) : "" },
2277
2572
  cwd: { text: directories[0] ?? "", alternatives: directories.slice(1) },
2278
2573
  progress: { text: progressText ? theme.fg("text", progressText) : "" },
2279
2574
  extensions: { text: "" },
@@ -2290,6 +2585,9 @@ export default function (pi: ExtensionAPI) {
2290
2585
  });
2291
2586
 
2292
2587
  pi.on("session_shutdown", async (_event, ctx) => {
2588
+ sessionOpen = false;
2589
+ completionModelRegistry = undefined;
2590
+ sessionEpoch++;
2293
2591
  progress.shutdown();
2294
2592
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
2295
2593
  });