pi-bar 0.3.4 → 0.3.6

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,22 +3,23 @@
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, context usage, and extension statuses 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, live TLDR, 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 ❯ ✧ plan:active
9
+ ◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M ❯ ⓘ Inspecting package structure ❯ ✧ 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.4/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.6/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
+ - **Show pi-tldr inline** — when `pi-tldr` is installed, its current TLDR appears as a compact footer segment.
19
20
  - **Keep extension statuses visible** — statuses set with `ctx.ui.setStatus()` still appear in the custom footer.
20
21
 
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.
22
+ 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 / tldr / extension-status line that refreshes when model, thinking level, context usage, pi-tldr, or extension statuses change.
22
23
 
23
24
  ## Install
24
25
 
@@ -39,11 +40,27 @@ pi-bar works out of the box, but you can tune it with environment variables befo
39
40
  ### Show or hide segments
40
41
 
41
42
  ```bash
42
- PI_BAR_SHOW=model,thinking,context,extensions pi
43
+ PI_BAR_SHOW=model,thinking,context,tldr,extensions pi
43
44
  PI_BAR_SHOW=model,context pi
44
45
  ```
45
46
 
46
- Allowed segments are `model`, `thinking`, `context`, and `extensions`. The `extensions` segment is hidden when no extension has set a status.
47
+ Allowed segments are `model`, `thinking`, `context`, `tldr`, and `extensions`. The `tldr` segment is hidden unless `pi-tldr` has a current TLDR. The `extensions` segment is hidden when no extension has set a status.
48
+
49
+ ### Show pi-tldr in the footer
50
+
51
+ Install [pi-tldr](https://www.npmjs.com/package/pi-tldr) alongside pi-bar:
52
+
53
+ ```bash
54
+ pi install npm:pi-tldr
55
+ ```
56
+
57
+ pi-bar captures pi-tldr's `pi-tldr` widget and renders its current text as the `tldr` segment. Limit segment width with:
58
+
59
+ ```bash
60
+ PI_BAR_TLDR_MAX_WIDTH=48 pi
61
+ ```
62
+
63
+ Default is `64`. Set `PI_BAR_SHOW` without `tldr` to hide it.
47
64
 
48
65
  ### Configure extension statuses
49
66
 
@@ -56,7 +73,7 @@ Use `/bar` inside pi to open an interactive status-visibility picker.
56
73
 
57
74
  Toggle each status key between `shown` and `hidden`. The `New statuses` row controls whether status keys discovered later are shown or hidden by default.
58
75
 
59
- Status filter choices persist in the current session. Advanced command forms like `/bar status hide <key>` still work for scripting.
76
+ Status filter choices persist globally across pi sessions. Set `PI_BAR_CONFIG=/path/to/config.json` before launching pi to override the default config path (`~/.pi/agent/pi-bar.json`). Advanced command forms like `/bar status hide <key>` still work for scripting.
60
77
 
61
78
  ### Change context thresholds
62
79
 
@@ -76,10 +93,10 @@ PI_BAR_ICONS=nerdfont pi
76
93
 
77
94
  Supported presets:
78
95
 
79
- - `unicode` — default, e.g. `◈ gpt-5.5 ❯ ✦ think:medium ❯ ◷ 42.7% / 272k ❯ ✧ plan:active`
80
- - `plain` — maximum compatibility, e.g. `model:gpt-5.5 | think:medium | ctx:42.7% / 272k | ext:plan:active`
81
- - `emoji` — colorful emoji markers, e.g. `🤖 gpt-5.5 › ✨ think:medium › 🧭 42.7% / 272k › 🧩 plan:active`
82
- - `nerdfont` — icon-font glyphs for terminals configured with a Nerd Font, e.g. `󰚩 gpt-5.5  󰧑 think:medium  󰍛 42.7% / 272k  󰐱 plan:active`
96
+ - `unicode` — default, e.g. `◈ gpt-5.5 ❯ ✦ think:medium ❯ ◷ 42.7% / 272k ❯ ⓘ Reading files ❯ ✧ plan:active`
97
+ - `plain` — maximum compatibility, e.g. `model:gpt-5.5 | think:medium | ctx:42.7% / 272k | tldr:Reading files | ext:plan:active`
98
+ - `emoji` — colorful emoji markers, e.g. `🤖 gpt-5.5 › ✨ think:medium › 🧭 42.7% / 272k › 📝 Reading files › 🧩 plan:active`
99
+ - `nerdfont` — icon-font glyphs for terminals configured with a Nerd Font, e.g. `󰚩 gpt-5.5  󰧑 think:medium  󰍛 42.7% / 272k  󰦨 Reading files  󰐱 plan:active`
83
100
 
84
101
  ## Compatibility
85
102
 
@@ -91,6 +108,7 @@ PI_BAR_ICONS=plain pi
91
108
 
92
109
  ## Pairs well with
93
110
 
111
+ - **[pi-tldr](https://www.npmjs.com/package/pi-tldr)** — show live summaries above the input bar; pi-bar mirrors them into the footer.
94
112
  - **[pi-chrome](https://www.npmjs.com/package/pi-chrome)** — give your Pi agent your real, signed-in Chrome. Use pi-bar's red-context threshold as the signal to wrap up long browser scrapes before context overflows.
95
113
  - **[pi-qq](https://www.npmjs.com/package/pi-qq)** — ask side questions about what the agent just did without polluting the transcript.
96
114
 
@@ -2,7 +2,7 @@
2
2
  * pi-bar — footer/statusline extension.
3
3
  *
4
4
  * Replaces the built-in footer with left-aligned segments:
5
- * ◈ <model name> ❯ ✦ think:<level> ❯ ◷ <context% / window> ❯ ✧ <extension statuses>
5
+ * ◈ <model name> ❯ ✦ think:<level> ❯ ◷ <context% / window> ❯ ⓘ <tldr> ❯ ✧ <extension statuses>
6
6
  *
7
7
  * Examples:
8
8
  * ◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M
@@ -11,24 +11,32 @@
11
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,extensions
14
+ * PI_BAR_SHOW=model,thinking,context,tldr,extensions
15
15
  * PI_BAR_THRESHOLDS=70,90
16
16
  * PI_BAR_ICONS=unicode|plain|emoji|nerdfont
17
+ * PI_BAR_TLDR_MAX_WIDTH=64
17
18
  */
18
19
 
20
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { dirname, join } from "node:path";
19
23
  import {
20
24
  getSettingsListTheme,
21
25
  type ExtensionAPI,
22
26
  type ExtensionContext,
27
+ type Theme,
28
+ type ThemeColor,
23
29
  } from "@earendil-works/pi-coding-agent";
24
30
  import {
25
31
  Container,
32
+ type Component,
26
33
  type SettingItem,
27
34
  SettingsList,
35
+ type TUI,
28
36
  truncateToWidth,
29
37
  } from "@earendil-works/pi-tui";
30
38
 
31
- type SegmentName = "model" | "thinking" | "context" | "extensions";
39
+ type SegmentName = "model" | "thinking" | "context" | "tldr" | "extensions";
32
40
  type IconPreset = "unicode" | "plain" | "emoji" | "nerdfont";
33
41
  type StatusFilter =
34
42
  | { mode: "all"; hidden: Set<string> }
@@ -36,13 +44,25 @@ type StatusFilter =
36
44
  type SerializedStatusFilter =
37
45
  | { mode: "all"; hidden: string[] }
38
46
  | { mode: "only"; shown: string[] };
47
+ type TldrWidgetFactory = (tui: TUI, theme: Theme) => Component & {
48
+ dispose?(): void;
49
+ };
50
+ type CapturedTldrWidget =
51
+ | { kind: "lines"; lines: string[] }
52
+ | { kind: "factory"; factory: TldrWidgetFactory };
39
53
 
40
54
  const STATUS_FILTER_ENTRY_TYPE = "pi-bar-status-filter";
55
+ const TLDR_WIDGET_KEY = "pi-tldr";
56
+ const DEFAULT_TLDR_MAX_WIDTH = 64;
57
+ const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
58
+ const CONFIG_PATH =
59
+ process.env.PI_BAR_CONFIG ?? join(homedir(), ".pi", "agent", "pi-bar.json");
41
60
 
42
61
  const DEFAULT_SEGMENTS: SegmentName[] = [
43
62
  "model",
44
63
  "thinking",
45
64
  "context",
65
+ "tldr",
46
66
  "extensions",
47
67
  ];
48
68
  const DEFAULT_WARNING_THRESHOLD = 70;
@@ -54,6 +74,7 @@ const ICON_PRESETS: Record<
54
74
  model: string;
55
75
  thinking: string;
56
76
  context: string;
77
+ tldr: string;
57
78
  extensions: string;
58
79
  separator: string;
59
80
  }
@@ -62,6 +83,7 @@ const ICON_PRESETS: Record<
62
83
  model: "◈",
63
84
  thinking: "✦",
64
85
  context: "◷",
86
+ tldr: "ⓘ",
65
87
  extensions: "✧",
66
88
  separator: "❯",
67
89
  },
@@ -69,6 +91,7 @@ const ICON_PRESETS: Record<
69
91
  model: "model",
70
92
  thinking: "think",
71
93
  context: "ctx",
94
+ tldr: "tldr",
72
95
  extensions: "ext",
73
96
  separator: "|",
74
97
  },
@@ -76,6 +99,7 @@ const ICON_PRESETS: Record<
76
99
  model: "🤖",
77
100
  thinking: "✨",
78
101
  context: "🧭",
102
+ tldr: "📝",
79
103
  extensions: "🧩",
80
104
  separator: "›",
81
105
  },
@@ -83,6 +107,7 @@ const ICON_PRESETS: Record<
83
107
  model: "󰚩",
84
108
  thinking: "󰧑",
85
109
  context: "󰍛",
110
+ tldr: "󰦨",
86
111
  extensions: "󰐱",
87
112
  separator: "",
88
113
  },
@@ -106,7 +131,7 @@ function formatModelName(id: string | undefined): string {
106
131
  return base.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
107
132
  }
108
133
 
109
- function thinkingColor(level: string): string {
134
+ function thinkingColor(level: string): ThemeColor {
110
135
  switch (level) {
111
136
  case "off":
112
137
  return "thinkingOff";
@@ -132,7 +157,7 @@ function contextColor(
132
157
  percent: number | null | undefined,
133
158
  warningThreshold: number,
134
159
  errorThreshold: number,
135
- ): string {
160
+ ): ThemeColor {
136
161
  if (percent === null || percent === undefined) return "muted";
137
162
  if (percent >= errorThreshold) return "error";
138
163
  if (percent >= warningThreshold) return "warning";
@@ -147,7 +172,7 @@ function parseSegments(): SegmentName[] {
147
172
  .split(",")
148
173
  .map((segment) => segment.trim().toLowerCase())
149
174
  .filter((segment): segment is SegmentName =>
150
- ["model", "thinking", "context", "extensions"].includes(segment),
175
+ ["model", "thinking", "context", "tldr", "extensions"].includes(segment),
151
176
  );
152
177
 
153
178
  return requested.length > 0 ? requested : DEFAULT_SEGMENTS;
@@ -187,6 +212,63 @@ function parseIconPreset(): IconPreset {
187
212
  return "unicode";
188
213
  }
189
214
 
215
+ function parseTldrMaxWidth(): number {
216
+ const raw = process.env.PI_BAR_TLDR_MAX_WIDTH;
217
+ if (!raw) return DEFAULT_TLDR_MAX_WIDTH;
218
+
219
+ const value = Number.parseInt(raw, 10);
220
+ if (Number.isFinite(value) && value >= 10 && value <= 500) return value;
221
+ return DEFAULT_TLDR_MAX_WIDTH;
222
+ }
223
+
224
+ function stripAnsi(value: string): string {
225
+ return value.replace(ANSI_PATTERN, "");
226
+ }
227
+
228
+ function extractTldrText(lines: readonly string[]): string | null {
229
+ const parts = lines
230
+ .map(stripAnsi)
231
+ .map((line) => line.trimEnd())
232
+ .filter((line) => line.trim().length > 0)
233
+ .filter((line) => !/^[╭╰].*[╮╯]$/.test(line.trim()))
234
+ .map((line) => {
235
+ const trimmed = line.trim();
236
+ if (trimmed.startsWith("│") && trimmed.endsWith("│")) {
237
+ return trimmed.slice(1, -1).trim();
238
+ }
239
+ return trimmed.replace(/^tldr:\s*/i, "").trim();
240
+ })
241
+ .filter(Boolean);
242
+
243
+ const text = parts.join(" ").replace(/\s+/g, " ").trim();
244
+ return text.length > 0 ? text : null;
245
+ }
246
+
247
+ function renderCapturedTldr(
248
+ widget: CapturedTldrWidget | undefined,
249
+ tui: TUI,
250
+ theme: Theme,
251
+ width: number,
252
+ maxWidth: number,
253
+ ): string | null {
254
+ if (!widget) return null;
255
+
256
+ let lines: string[];
257
+ if (widget.kind === "lines") {
258
+ lines = widget.lines;
259
+ } else {
260
+ const component = widget.factory(tui, theme);
261
+ try {
262
+ lines = component.render(Math.max(12, width));
263
+ } finally {
264
+ component.dispose?.();
265
+ }
266
+ }
267
+
268
+ const text = extractTldrText(lines);
269
+ return text ? truncateToWidth(text, maxWidth) : null;
270
+ }
271
+
190
272
  function prefixed(icon: string, text: string, preset: IconPreset): string {
191
273
  if (preset === "plain") return `${icon}:${text}`;
192
274
  return `${icon} ${text}`;
@@ -250,6 +332,29 @@ function describeStatusFilter(filter: StatusFilter): string {
250
332
  return hidden.length > 0 ? `showing all except: ${hidden.join(", ")}` : "showing all";
251
333
  }
252
334
 
335
+ function readGlobalStatusFilter(): StatusFilter | null {
336
+ try {
337
+ const data = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as {
338
+ statusFilter?: unknown;
339
+ };
340
+ return parseSerializedStatusFilter(data.statusFilter ?? data);
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ function writeGlobalStatusFilter(filter: StatusFilter): void {
347
+ const data = JSON.stringify(
348
+ { statusFilter: serializeStatusFilter(filter) },
349
+ null,
350
+ 2,
351
+ );
352
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
353
+ const tmpPath = `${CONFIG_PATH}.${process.pid}.tmp`;
354
+ writeFileSync(tmpPath, `${data}\n`, "utf8");
355
+ renameSync(tmpPath, CONFIG_PATH);
356
+ }
357
+
253
358
  function getKnownStatusKeys(filter: StatusFilter, seenStatusKeys: Set<string>): string[] {
254
359
  const keys = new Set(seenStatusKeys);
255
360
  if (filter.mode === "only") {
@@ -263,19 +368,52 @@ function getKnownStatusKeys(filter: StatusFilter, seenStatusKeys: Set<string>):
263
368
  export default function (pi: ExtensionAPI) {
264
369
  let requestRender: (() => void) | undefined;
265
370
  let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
371
+ let capturedTldrWidget: CapturedTldrWidget | undefined;
266
372
  const seenStatusKeys = new Set<string>();
373
+ const patchedUis = new WeakSet<object>();
267
374
  const refresh = () => requestRender?.();
375
+ const installTldrWidgetCapture = (ctx: ExtensionContext) => {
376
+ if (!ctx.hasUI) return;
377
+ const ui = ctx.ui as ExtensionContext["ui"];
378
+ if (patchedUis.has(ui)) return;
379
+ patchedUis.add(ui);
380
+
381
+ const originalSetWidget = ui.setWidget.bind(ui);
382
+ ui.setWidget = ((key: string, content: unknown, options?: unknown) => {
383
+ if (key === TLDR_WIDGET_KEY) {
384
+ if (Array.isArray(content)) {
385
+ capturedTldrWidget = { kind: "lines", lines: content };
386
+ } else if (typeof content === "function") {
387
+ capturedTldrWidget = {
388
+ kind: "factory",
389
+ factory: content as TldrWidgetFactory,
390
+ };
391
+ } else {
392
+ capturedTldrWidget = undefined;
393
+ }
394
+ refresh();
395
+ }
396
+
397
+ return originalSetWidget(
398
+ key,
399
+ content as Parameters<ExtensionContext["ui"]["setWidget"]>[1],
400
+ options as Parameters<ExtensionContext["ui"]["setWidget"]>[2],
401
+ );
402
+ }) as ExtensionContext["ui"]["setWidget"];
403
+ };
268
404
  const restoreStatusFilter = (ctx: ExtensionContext) => {
269
- let restoredFilter: StatusFilter | null = null;
270
- for (const entry of ctx.sessionManager.getBranch()) {
271
- if (entry.type === "custom" && entry.customType === STATUS_FILTER_ENTRY_TYPE) {
272
- restoredFilter = parseSerializedStatusFilter(entry.data);
405
+ let restoredFilter = readGlobalStatusFilter();
406
+ if (!restoredFilter) {
407
+ for (const entry of ctx.sessionManager.getBranch()) {
408
+ if (entry.type === "custom" && entry.customType === STATUS_FILTER_ENTRY_TYPE) {
409
+ restoredFilter = parseSerializedStatusFilter(entry.data);
410
+ }
273
411
  }
274
412
  }
275
413
  statusFilter = restoredFilter ?? { mode: "all", hidden: new Set() };
276
414
  };
277
415
  const persistStatusFilter = () => {
278
- pi.appendEntry(STATUS_FILTER_ENTRY_TYPE, serializeStatusFilter(statusFilter));
416
+ writeGlobalStatusFilter(statusFilter);
279
417
  refresh();
280
418
  };
281
419
  const openStatusConfigurator = async (ctx: ExtensionContext) => {
@@ -451,11 +589,14 @@ export default function (pi: ExtensionAPI) {
451
589
  pi.on("turn_end", async () => refresh());
452
590
  pi.on("message_end", async () => refresh());
453
591
  pi.on("session_tree", async (_event, ctx) => {
592
+ installTldrWidgetCapture(ctx);
454
593
  restoreStatusFilter(ctx);
455
594
  refresh();
456
595
  });
457
596
 
458
597
  pi.on("session_start", async (_event, ctx) => {
598
+ capturedTldrWidget = undefined;
599
+ installTldrWidgetCapture(ctx);
459
600
  restoreStatusFilter(ctx);
460
601
 
461
602
  if (!ctx.hasUI) return;
@@ -464,6 +605,7 @@ export default function (pi: ExtensionAPI) {
464
605
  const iconPreset = parseIconPreset();
465
606
  const icons = ICON_PRESETS[iconPreset];
466
607
  const { warningThreshold, errorThreshold } = parseThresholds();
608
+ const tldrMaxWidth = parseTldrMaxWidth();
467
609
 
468
610
  ctx.ui.setFooter((tui, theme, footerData) => {
469
611
  requestRender = () => tui.requestRender();
@@ -491,6 +633,13 @@ export default function (pi: ExtensionAPI) {
491
633
  const contextText = usage
492
634
  ? `${usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "—%"} / ${formatTokens(usage.contextWindow)}`
493
635
  : "—";
636
+ const tldrText = renderCapturedTldr(
637
+ capturedTldrWidget,
638
+ tui,
639
+ theme,
640
+ width,
641
+ tldrMaxWidth,
642
+ );
494
643
 
495
644
  const segmentRenderers: Record<SegmentName, string | null> = {
496
645
  model: theme.fg(
@@ -505,6 +654,9 @@ export default function (pi: ExtensionAPI) {
505
654
  contextSegmentColor,
506
655
  prefixed(icons.context, contextText, iconPreset),
507
656
  ),
657
+ tldr: tldrText
658
+ ? theme.fg("muted", prefixed(icons.tldr, tldrText, iconPreset))
659
+ : null,
508
660
  extensions: extensionStatuses
509
661
  ? theme.fg(
510
662
  "muted",
@@ -526,6 +678,7 @@ export default function (pi: ExtensionAPI) {
526
678
  });
527
679
 
528
680
  pi.on("session_shutdown", async (_event, ctx) => {
681
+ capturedTldrWidget = undefined;
529
682
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
530
683
  });
531
684
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-bar",
3
- "version": "0.3.4",
4
- "description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, context usage, and extension statuses.",
3
+ "version": "0.3.6",
4
+ "description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, context usage, pi-tldr, and extension statuses.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -13,7 +13,9 @@
13
13
  "model",
14
14
  "thinking",
15
15
  "context",
16
- "context-window"
16
+ "context-window",
17
+ "tldr",
18
+ "pi-tldr"
17
19
  ],
18
20
  "license": "MIT",
19
21
  "repository": {
@@ -46,6 +48,6 @@
46
48
  "extensions": [
47
49
  "./extensions/status-footer.ts"
48
50
  ],
49
- "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.4/assets/screenshot.png"
51
+ "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.6/assets/screenshot.png"
50
52
  }
51
53
  }