pi-bar 0.3.1 → 0.3.3
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 +27 -10
- package/extensions/status-footer.ts +208 -16
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,21 +3,22 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-bar)
|
|
4
4
|
[](https://www.npmjs.com/package/pi-bar)
|
|
5
5
|
|
|
6
|
-
**Never accidentally run Opus on a typo again.** pi-bar puts the active model, thinking level,
|
|
6
|
+
**Never accidentally run Opus on a typo again.** pi-bar puts the active model, thinking level, context usage, and extension statuses in pi's footer so you catch wrong-model / wrong-reasoning / context-pressure problems before they cost you.
|
|
7
7
|
|
|
8
8
|
```text
|
|
9
|
-
◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M
|
|
9
|
+
◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M ❯ ✧ plan:active
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-

|
|
13
13
|
|
|
14
14
|
## Why use it?
|
|
15
15
|
|
|
16
16
|
- **See the active model at a glance** — catch accidental model switches before an expensive or sensitive task starts.
|
|
17
17
|
- **Track thinking level in place** — immediately notice when you are using the wrong reasoning setting.
|
|
18
18
|
- **Watch context pressure early** — context usage turns green, yellow, then red as you approach the limit.
|
|
19
|
+
- **Keep extension statuses visible** — statuses set with `ctx.ui.setStatus()` still appear in the custom footer.
|
|
19
20
|
|
|
20
|
-
pi-bar is intentionally tiny: one small extension, no broad behavior changes, and no commands to learn. It replaces pi's built-in footer with a compact model / thinking / context status line that refreshes when
|
|
21
|
+
pi-bar is intentionally tiny: one small extension, no broad behavior changes, and no commands to learn. It replaces pi's built-in footer with a compact model / thinking / context / extension-status line that refreshes when model, thinking level, context usage, or extension statuses change.
|
|
21
22
|
|
|
22
23
|
## Install
|
|
23
24
|
|
|
@@ -38,11 +39,27 @@ pi-bar works out of the box, but you can tune it with environment variables befo
|
|
|
38
39
|
### Show or hide segments
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
|
-
PI_BAR_SHOW=model,thinking,context pi
|
|
42
|
+
PI_BAR_SHOW=model,thinking,context,extensions pi
|
|
42
43
|
PI_BAR_SHOW=model,context pi
|
|
43
44
|
```
|
|
44
45
|
|
|
45
|
-
Allowed segments are `model`, `thinking`, and `
|
|
46
|
+
Allowed segments are `model`, `thinking`, `context`, and `extensions`. The `extensions` segment is hidden when no extension has set a status.
|
|
47
|
+
|
|
48
|
+
### Configure extension statuses
|
|
49
|
+
|
|
50
|
+
pi-bar shows statuses set by other extensions with `ctx.ui.setStatus()`.
|
|
51
|
+
Use `/bar status` inside pi to choose which status keys appear.
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
/bar status list
|
|
55
|
+
/bar status hide plan-mode
|
|
56
|
+
/bar status show plan-mode
|
|
57
|
+
/bar status only plan-mode,preset
|
|
58
|
+
/bar status none
|
|
59
|
+
/bar status all
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Status filter choices persist in the current session.
|
|
46
63
|
|
|
47
64
|
### Change context thresholds
|
|
48
65
|
|
|
@@ -62,10 +79,10 @@ PI_BAR_ICONS=nerdfont pi
|
|
|
62
79
|
|
|
63
80
|
Supported presets:
|
|
64
81
|
|
|
65
|
-
- `unicode` — default, e.g. `◈ gpt-5.5 ❯ ✦ think:medium ❯ ◷ 42.7% / 272k`
|
|
66
|
-
- `plain` — maximum compatibility, e.g. `model:gpt-5.5 | think:medium | ctx:42.7% / 272k`
|
|
67
|
-
- `emoji` — colorful emoji markers, e.g. `🤖 gpt-5.5 › ✨ think:medium › 🧭 42.7% / 272k`
|
|
68
|
-
- `nerdfont` — icon-font glyphs for terminals configured with a Nerd Font, e.g.
|
|
82
|
+
- `unicode` — default, e.g. `◈ gpt-5.5 ❯ ✦ think:medium ❯ ◷ 42.7% / 272k ❯ ✧ plan:active`
|
|
83
|
+
- `plain` — maximum compatibility, e.g. `model:gpt-5.5 | think:medium | ctx:42.7% / 272k | ext:plan:active`
|
|
84
|
+
- `emoji` — colorful emoji markers, e.g. `🤖 gpt-5.5 › ✨ think:medium › 🧭 42.7% / 272k › 🧩 plan:active`
|
|
85
|
+
- `nerdfont` — icon-font glyphs for terminals configured with a Nerd Font, e.g. ` gpt-5.5 think:medium 42.7% / 272k plan:active`
|
|
69
86
|
|
|
70
87
|
## Compatibility
|
|
71
88
|
|
|
@@ -1,39 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-bar — footer/statusline extension.
|
|
3
3
|
*
|
|
4
|
-
* Replaces the built-in footer with
|
|
5
|
-
* ◈ <model name> ❯ ✦ think:<level> ❯ ◷ <context% / window>
|
|
4
|
+
* Replaces the built-in footer with left-aligned segments:
|
|
5
|
+
* ◈ <model name> ❯ ✦ think:<level> ❯ ◷ <context% / window> ❯ ✧ <extension statuses>
|
|
6
6
|
*
|
|
7
7
|
* Examples:
|
|
8
8
|
* ◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M
|
|
9
9
|
*
|
|
10
|
-
* Re-renders on model change, thinking-level change,
|
|
11
|
-
* assistant turn so context usage stays current.
|
|
10
|
+
* Re-renders on model change, thinking-level change, status updates,
|
|
11
|
+
* and after each assistant turn so context usage stays current.
|
|
12
12
|
*
|
|
13
13
|
* Configuration env vars:
|
|
14
|
-
* PI_BAR_SHOW=model,thinking,context
|
|
14
|
+
* PI_BAR_SHOW=model,thinking,context,extensions
|
|
15
15
|
* PI_BAR_THRESHOLDS=70,90
|
|
16
16
|
* PI_BAR_ICONS=unicode|plain|emoji|nerdfont
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
21
21
|
|
|
22
|
-
type SegmentName = "model" | "thinking" | "context";
|
|
22
|
+
type SegmentName = "model" | "thinking" | "context" | "extensions";
|
|
23
23
|
type IconPreset = "unicode" | "plain" | "emoji" | "nerdfont";
|
|
24
|
+
type StatusFilter =
|
|
25
|
+
| { mode: "all"; hidden: Set<string> }
|
|
26
|
+
| { mode: "only"; shown: Set<string> };
|
|
27
|
+
type SerializedStatusFilter =
|
|
28
|
+
| { mode: "all"; hidden: string[] }
|
|
29
|
+
| { mode: "only"; shown: string[] };
|
|
24
30
|
|
|
25
|
-
const
|
|
31
|
+
const STATUS_FILTER_ENTRY_TYPE = "pi-bar-status-filter";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_SEGMENTS: SegmentName[] = [
|
|
34
|
+
"model",
|
|
35
|
+
"thinking",
|
|
36
|
+
"context",
|
|
37
|
+
"extensions",
|
|
38
|
+
];
|
|
26
39
|
const DEFAULT_WARNING_THRESHOLD = 70;
|
|
27
40
|
const DEFAULT_ERROR_THRESHOLD = 90;
|
|
28
41
|
|
|
29
42
|
const ICON_PRESETS: Record<
|
|
30
43
|
IconPreset,
|
|
31
|
-
{
|
|
44
|
+
{
|
|
45
|
+
model: string;
|
|
46
|
+
thinking: string;
|
|
47
|
+
context: string;
|
|
48
|
+
extensions: string;
|
|
49
|
+
separator: string;
|
|
50
|
+
}
|
|
32
51
|
> = {
|
|
33
|
-
unicode: {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
unicode: {
|
|
53
|
+
model: "◈",
|
|
54
|
+
thinking: "✦",
|
|
55
|
+
context: "◷",
|
|
56
|
+
extensions: "✧",
|
|
57
|
+
separator: "❯",
|
|
58
|
+
},
|
|
59
|
+
plain: {
|
|
60
|
+
model: "model",
|
|
61
|
+
thinking: "think",
|
|
62
|
+
context: "ctx",
|
|
63
|
+
extensions: "ext",
|
|
64
|
+
separator: "|",
|
|
65
|
+
},
|
|
66
|
+
emoji: {
|
|
67
|
+
model: "🤖",
|
|
68
|
+
thinking: "✨",
|
|
69
|
+
context: "🧭",
|
|
70
|
+
extensions: "🧩",
|
|
71
|
+
separator: "›",
|
|
72
|
+
},
|
|
73
|
+
nerdfont: {
|
|
74
|
+
model: "",
|
|
75
|
+
thinking: "",
|
|
76
|
+
context: "",
|
|
77
|
+
extensions: "",
|
|
78
|
+
separator: "",
|
|
79
|
+
},
|
|
37
80
|
};
|
|
38
81
|
|
|
39
82
|
function formatTokens(n: number): string {
|
|
@@ -95,7 +138,7 @@ function parseSegments(): SegmentName[] {
|
|
|
95
138
|
.split(",")
|
|
96
139
|
.map((segment) => segment.trim().toLowerCase())
|
|
97
140
|
.filter((segment): segment is SegmentName =>
|
|
98
|
-
["model", "thinking", "context"].includes(segment),
|
|
141
|
+
["model", "thinking", "context", "extensions"].includes(segment),
|
|
99
142
|
);
|
|
100
143
|
|
|
101
144
|
return requested.length > 0 ? requested : DEFAULT_SEGMENTS;
|
|
@@ -140,16 +183,153 @@ function prefixed(icon: string, text: string, preset: IconPreset): string {
|
|
|
140
183
|
return `${icon} ${text}`;
|
|
141
184
|
}
|
|
142
185
|
|
|
186
|
+
function shouldShowStatus(key: string, filter: StatusFilter): boolean {
|
|
187
|
+
if (filter.mode === "only") return filter.shown.has(key);
|
|
188
|
+
return !filter.hidden.has(key);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function formatExtensionStatuses(
|
|
192
|
+
statuses: ReadonlyMap<string, string>,
|
|
193
|
+
filter: StatusFilter,
|
|
194
|
+
seenStatusKeys: Set<string>,
|
|
195
|
+
): string | null {
|
|
196
|
+
const parts = Array.from(statuses.entries())
|
|
197
|
+
.filter(([, text]) => text.trim().length > 0)
|
|
198
|
+
.filter(([key]) => {
|
|
199
|
+
seenStatusKeys.add(key);
|
|
200
|
+
return shouldShowStatus(key, filter);
|
|
201
|
+
})
|
|
202
|
+
.map(([key, text]) => `${key}:${text}`);
|
|
203
|
+
|
|
204
|
+
return parts.length > 0 ? parts.join(" ") : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function serializeStatusFilter(filter: StatusFilter): SerializedStatusFilter {
|
|
208
|
+
if (filter.mode === "only") {
|
|
209
|
+
return { mode: "only", shown: Array.from(filter.shown).sort() };
|
|
210
|
+
}
|
|
211
|
+
return { mode: "all", hidden: Array.from(filter.hidden).sort() };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseSerializedStatusFilter(value: unknown): StatusFilter | null {
|
|
215
|
+
if (!value || typeof value !== "object") return null;
|
|
216
|
+
const data = value as Partial<SerializedStatusFilter>;
|
|
217
|
+
|
|
218
|
+
if (data.mode === "only" && Array.isArray(data.shown)) {
|
|
219
|
+
return { mode: "only", shown: new Set(data.shown) };
|
|
220
|
+
}
|
|
221
|
+
if (data.mode === "all" && Array.isArray(data.hidden)) {
|
|
222
|
+
return { mode: "all", hidden: new Set(data.hidden) };
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function splitStatusKeys(raw: string): string[] {
|
|
228
|
+
return raw
|
|
229
|
+
.split(/[\s,]+/)
|
|
230
|
+
.map((key) => key.trim())
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function describeStatusFilter(filter: StatusFilter): string {
|
|
235
|
+
if (filter.mode === "only") {
|
|
236
|
+
const shown = Array.from(filter.shown).sort();
|
|
237
|
+
return shown.length > 0 ? `showing only: ${shown.join(", ")}` : "showing none";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const hidden = Array.from(filter.hidden).sort();
|
|
241
|
+
return hidden.length > 0 ? `showing all except: ${hidden.join(", ")}` : "showing all";
|
|
242
|
+
}
|
|
243
|
+
|
|
143
244
|
export default function (pi: ExtensionAPI) {
|
|
144
245
|
let requestRender: (() => void) | undefined;
|
|
246
|
+
let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
|
|
247
|
+
const seenStatusKeys = new Set<string>();
|
|
145
248
|
const refresh = () => requestRender?.();
|
|
249
|
+
const restoreStatusFilter = (ctx: ExtensionContext) => {
|
|
250
|
+
let restoredFilter: StatusFilter | null = null;
|
|
251
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
252
|
+
if (entry.type === "custom" && entry.customType === STATUS_FILTER_ENTRY_TYPE) {
|
|
253
|
+
restoredFilter = parseSerializedStatusFilter(entry.data);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
statusFilter = restoredFilter ?? { mode: "all", hidden: new Set() };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
pi.registerCommand("bar", {
|
|
260
|
+
description: "Configure pi-bar footer status segments",
|
|
261
|
+
handler: async (args, ctx) => {
|
|
262
|
+
const [section, action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
263
|
+
if (!section || section === "status" || section === "statuses") {
|
|
264
|
+
const keys = splitStatusKeys(rest.join(" "));
|
|
265
|
+
|
|
266
|
+
switch (action) {
|
|
267
|
+
case undefined:
|
|
268
|
+
case "list":
|
|
269
|
+
case "ls": {
|
|
270
|
+
const known = Array.from(seenStatusKeys).sort();
|
|
271
|
+
ctx.ui.notify(
|
|
272
|
+
`pi-bar statuses: ${describeStatusFilter(statusFilter)}${known.length > 0 ? `; known: ${known.join(", ")}` : "; known: none yet"}`,
|
|
273
|
+
"info",
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
case "all":
|
|
278
|
+
statusFilter = { mode: "all", hidden: new Set() };
|
|
279
|
+
break;
|
|
280
|
+
case "none":
|
|
281
|
+
statusFilter = { mode: "only", shown: new Set() };
|
|
282
|
+
break;
|
|
283
|
+
case "only":
|
|
284
|
+
statusFilter = { mode: "only", shown: new Set(keys) };
|
|
285
|
+
break;
|
|
286
|
+
case "hide":
|
|
287
|
+
if (statusFilter.mode === "only") {
|
|
288
|
+
for (const key of keys) statusFilter.shown.delete(key);
|
|
289
|
+
} else {
|
|
290
|
+
for (const key of keys) statusFilter.hidden.add(key);
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
case "show":
|
|
294
|
+
if (statusFilter.mode === "only") {
|
|
295
|
+
for (const key of keys) statusFilter.shown.add(key);
|
|
296
|
+
} else {
|
|
297
|
+
for (const key of keys) statusFilter.hidden.delete(key);
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
default:
|
|
301
|
+
ctx.ui.notify(
|
|
302
|
+
"Usage: /bar status [list|all|none|only <keys>|show <keys>|hide <keys>]",
|
|
303
|
+
"warning",
|
|
304
|
+
);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
pi.appendEntry(STATUS_FILTER_ENTRY_TYPE, serializeStatusFilter(statusFilter));
|
|
309
|
+
refresh();
|
|
310
|
+
ctx.ui.notify(`pi-bar statuses: ${describeStatusFilter(statusFilter)}`, "info");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
ctx.ui.notify(
|
|
315
|
+
"Usage: /bar status [list|all|none|only <keys>|show <keys>|hide <keys>]",
|
|
316
|
+
"warning",
|
|
317
|
+
);
|
|
318
|
+
},
|
|
319
|
+
});
|
|
146
320
|
|
|
147
321
|
pi.on("model_select", async () => refresh());
|
|
148
322
|
pi.on("thinking_level_select", async () => refresh());
|
|
149
323
|
pi.on("turn_end", async () => refresh());
|
|
150
324
|
pi.on("message_end", async () => refresh());
|
|
325
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
326
|
+
restoreStatusFilter(ctx);
|
|
327
|
+
refresh();
|
|
328
|
+
});
|
|
151
329
|
|
|
152
330
|
pi.on("session_start", async (_event, ctx) => {
|
|
331
|
+
restoreStatusFilter(ctx);
|
|
332
|
+
|
|
153
333
|
if (!ctx.hasUI) return;
|
|
154
334
|
|
|
155
335
|
const visibleSegments = parseSegments();
|
|
@@ -157,7 +337,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
157
337
|
const icons = ICON_PRESETS[iconPreset];
|
|
158
338
|
const { warningThreshold, errorThreshold } = parseThresholds();
|
|
159
339
|
|
|
160
|
-
ctx.ui.setFooter((tui, theme) => {
|
|
340
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
161
341
|
requestRender = () => tui.requestRender();
|
|
162
342
|
|
|
163
343
|
return {
|
|
@@ -168,6 +348,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
168
348
|
render(width: number): string[] {
|
|
169
349
|
const modelName = formatModelName(ctx.model?.id);
|
|
170
350
|
const thinkingLevel = String(pi.getThinkingLevel());
|
|
351
|
+
const extensionStatuses = formatExtensionStatuses(
|
|
352
|
+
footerData?.getExtensionStatuses?.() ?? new Map(),
|
|
353
|
+
statusFilter,
|
|
354
|
+
seenStatusKeys,
|
|
355
|
+
);
|
|
171
356
|
const usage = ctx.getContextUsage();
|
|
172
357
|
const contextSegmentColor = contextColor(
|
|
173
358
|
usage?.percent,
|
|
@@ -179,7 +364,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
364
|
? `${usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "—%"} / ${formatTokens(usage.contextWindow)}`
|
|
180
365
|
: "—";
|
|
181
366
|
|
|
182
|
-
const segmentRenderers: Record<SegmentName, string> = {
|
|
367
|
+
const segmentRenderers: Record<SegmentName, string | null> = {
|
|
183
368
|
model: theme.fg(
|
|
184
369
|
"accent",
|
|
185
370
|
prefixed(icons.model, modelName, iconPreset),
|
|
@@ -192,11 +377,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
377
|
contextSegmentColor,
|
|
193
378
|
prefixed(icons.context, contextText, iconPreset),
|
|
194
379
|
),
|
|
380
|
+
extensions: extensionStatuses
|
|
381
|
+
? theme.fg(
|
|
382
|
+
"muted",
|
|
383
|
+
prefixed(icons.extensions, extensionStatuses, iconPreset),
|
|
384
|
+
)
|
|
385
|
+
: null,
|
|
195
386
|
};
|
|
196
387
|
|
|
197
388
|
const separator = ` ${theme.fg("dim", icons.separator)} `;
|
|
198
389
|
const line = visibleSegments
|
|
199
390
|
.map((segment) => segmentRenderers[segment])
|
|
391
|
+
.filter((segment): segment is string => segment !== null)
|
|
200
392
|
.join(separator);
|
|
201
393
|
|
|
202
394
|
return [truncateToWidth(line, width)];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-bar",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, and
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, context usage, and extension statuses.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"extensions": [
|
|
47
47
|
"./extensions/status-footer.ts"
|
|
48
48
|
],
|
|
49
|
-
"image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.
|
|
49
|
+
"image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.3/assets/screenshot.png"
|
|
50
50
|
}
|
|
51
51
|
}
|