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 CHANGED
@@ -3,21 +3,22 @@
3
3
  [![npm version](https://img.shields.io/npm/v/pi-bar.svg)](https://www.npmjs.com/package/pi-bar)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/pi-bar.svg)](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, and context usage in pi's footer so you catch wrong-model / wrong-reasoning / context-pressure problems before they cost you.
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
- ![pi-bar screenshot, three rows showing green, yellow, and red context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.0/assets/screenshot.png)
12
+ ![pi-bar screenshot, three rows showing green, yellow, and red context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.3/assets/screenshot.png)
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 the model changes, thinking level changes, and after assistant turns.
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 `context`.
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. `󰊩 gpt-5.5 󰮱 󰉑 think:medium 󰮱 󰍛 42.7% / 272k`
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 three left-aligned segments:
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, and after each
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 DEFAULT_SEGMENTS: SegmentName[] = ["model", "thinking", "context"];
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
- { model: string; thinking: string; context: string; separator: string }
44
+ {
45
+ model: string;
46
+ thinking: string;
47
+ context: string;
48
+ extensions: string;
49
+ separator: string;
50
+ }
32
51
  > = {
33
- unicode: { model: "◈", thinking: "✦", context: "◷", separator: "❯" },
34
- plain: { model: "model", thinking: "think", context: "ctx", separator: "|" },
35
- emoji: { model: "🤖", thinking: "✨", context: "🧭", separator: "›" },
36
- nerdfont: { model: "󰚩", thinking: "󰧑", context: "󰍛", separator: "" },
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.1",
4
- "description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, and context usage.",
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.0/assets/screenshot.png"
49
+ "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.3/assets/screenshot.png"
50
50
  }
51
51
  }