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.
- package/README.md +103 -20
- package/extensions/status-footer.ts +549 -251
- package/extensions/usage.ts +108 -0
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
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.
|
|
155
|
-
|
|
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(
|
|
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
|
|
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
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
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
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
-
|
|
2233
|
+
settings.handleInput(data);
|
|
1958
2234
|
tui.requestRender();
|
|
1959
2235
|
},
|
|
1960
2236
|
};
|
|
1961
2237
|
});
|
|
1962
2238
|
};
|
|
1963
|
-
const
|
|
1964
|
-
|
|
1965
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2048
|
-
return container.render(width);
|
|
2049
|
-
},
|
|
2050
|
-
invalidate() {
|
|
2051
|
-
container.invalidate();
|
|
2052
|
-
},
|
|
2247
|
+
...settings,
|
|
2053
2248
|
handleInput(data: string) {
|
|
2054
|
-
|
|
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: "
|
|
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
|
|
2065
|
-
if (
|
|
2066
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
2449
|
+
ctx.ui.notify(`pi-bar badges: ${describeStatusFilter(statusFilter)}`, "info");
|
|
2181
2450
|
return;
|
|
2182
2451
|
}
|
|
2183
2452
|
|
|
2184
2453
|
ctx.ui.notify(
|
|
2185
|
-
"
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
});
|