pi-bar 0.3.41 → 0.3.43

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
@@ -6,7 +6,7 @@
6
6
  **Never accidentally run Opus on a typo again.** pi-bar keeps your model, thinking level, context pressure, a live progress update, and any extension statuses visible in pi's footer.
7
7
 
8
8
  ```text
9
- claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Reviewing package structure ❯ plan:active ❯ queue:2
9
+ claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Reviewing package structure ❯ Plan active ❯ Queue: 2
10
10
  ```
11
11
 
12
12
  ![pi-bar with low context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.38/assets/screenshot-green.png)
@@ -35,6 +35,19 @@ If pi is already running after install, reload resources:
35
35
  /reload
36
36
  ```
37
37
 
38
+ ## Automatic layout
39
+
40
+ The footer adapts to terminal width without extra configuration:
41
+
42
+ - **Model, thinking level, and context percentage take priority.**
43
+ - Separators have two spaces on each side by default, for a roomier display.
44
+ - Progress text shrinks first. If space is still tight, separator padding reduces to one space per side before compacting content. CWD then compacts from its preferred path to `parent/project`, then `project`; context window size is omitted when needed.
45
+ - Extension badges stay in their published order. Badges that cannot fit are hidden whole, with `+N` showing how many are behind the overflow. Open `/bar status` to inspect their text and visibility by key.
46
+ - On very narrow terminals, progress and CWD yield to core information. If even the core cannot fit, the model name is truncated first. At extreme widths, not every value or overflow count can remain visible.
47
+ - Widening the terminal restores the full display. Thinking and context retain their semantic colors throughout.
48
+
49
+ Existing segment visibility settings still apply; CWD remains opt-in.
50
+
38
51
  ## Customization
39
52
 
40
53
  pi-bar works out of the box. Run `/bar` inside pi to choose which footer segments and extension statuses are shown:
@@ -99,7 +112,9 @@ Or in pi settings: `bar.progressModel`. Otherwise pi-bar picks a fast model you
99
112
 
100
113
  ### Configure extension statuses
101
114
 
102
- Other pi extensions can publish small status badges. Pi-bar collects them into the `extensions` segment, strips embedded terminal colors, and separates each badge with the same `❯` divider used by other footer segments. Run `/bar` or `/bar status` inside pi to pick which ones to show:
115
+ Other pi extensions can publish small status badges. Pi-bar displays their text as written, without prepending internal keys: `setStatus("mcp", "MCP: 2/2 servers")` appears as `MCP: 2/2 servers`, not `mcp:MCP: 2/2 servers`. Extensions should publish self-describing text; pi-bar does not guess labels for bare values.
116
+
117
+ Pi-bar strips embedded terminal colors and control sequences, and separates each badge with the same `❯` divider used by other footer segments. Keys remain available for filtering and identification in `/bar`; selecting a status row shows its current sanitized text. Run `/bar` or `/bar status` inside pi to inspect statuses or pick which ones to show:
103
118
 
104
119
  ```text
105
120
  /bar status
@@ -130,7 +145,7 @@ npm test
130
145
  npm run check
131
146
  ```
132
147
 
133
- Tests cover path formatting, terminal safety, width limits, and segment visibility/persistence. Test configuration mirrors Pi's `pi-ai/compat` loader alias; runtime dependencies remain optional peers supplied by Pi.
148
+ Tests cover path formatting, display-ready statuses, responsive layout, terminal safety, ANSI/Unicode width limits, resize behavior, and segment/status visibility persistence. Test configuration mirrors Pi's `pi-ai/compat` loader alias; runtime dependencies remain optional peers supplied by Pi.
134
149
 
135
150
  ## Security note
136
151
 
@@ -136,7 +136,6 @@ const DEFAULT_WARNING_THRESHOLD = 70;
136
136
  const DEFAULT_ERROR_THRESHOLD = 90;
137
137
 
138
138
  const SEGMENT_SEPARATOR = "❯";
139
- const EXTENSION_STATUS_SEPARATOR = SEGMENT_SEPARATOR;
140
139
 
141
140
  function formatTokens(n: number): string {
142
141
  if (n >= 1_000_000) {
@@ -195,6 +194,111 @@ export function formatCwd(
195
194
  return truncateToWidth(compact, maxWidth);
196
195
  }
197
196
 
197
+ /** Preferred directory display, then parent/project, then project only. */
198
+ export function cwdVariants(
199
+ cwd: string,
200
+ home = homedir(),
201
+ maxWidth = DEFAULT_CWD_MAX_WIDTH,
202
+ paths = process.platform === "win32" ? win32 : posix,
203
+ ): string[] {
204
+ const safeCwd = stripTerminalControls(cwd);
205
+ const leaf = paths.basename(safeCwd);
206
+ const parent = paths.basename(paths.dirname(safeCwd));
207
+ return [
208
+ formatCwd(cwd, home, maxWidth, paths),
209
+ ...[parent && leaf ? `${parent}${paths.sep}${leaf}` : leaf, leaf]
210
+ .filter(Boolean)
211
+ .map((text) => truncateToWidth(stripTerminalControls(text), maxWidth, "…")),
212
+ ].map(stripTerminalControls);
213
+ }
214
+
215
+ export type FooterSegment = {
216
+ name: SegmentName;
217
+ text: string;
218
+ /** Shorter, already styled representations, in preference order. */
219
+ alternatives?: readonly string[];
220
+ };
221
+
222
+ /** Pure, column-aware layout. The divider may be styled but has no padding. Never split a status badge. */
223
+ export function layoutFooter(
224
+ segments: readonly FooterSegment[],
225
+ badges: readonly string[],
226
+ width: number,
227
+ divider = SEGMENT_SEPARATOR,
228
+ ): string {
229
+ if (!Number.isFinite(width) || width <= 0) return "";
230
+ width = Math.floor(width);
231
+ let separator = ` ${divider} `;
232
+ const items = segments.map((segment) => ({ ...segment }));
233
+ const extensions = items.find((item) => item.name === "extensions");
234
+ let shownBadges = badges.length;
235
+ const updateBadges = () => {
236
+ if (!extensions) return;
237
+ const hidden = badges.length - shownBadges;
238
+ extensions.text = [...badges.slice(0, shownBadges), ...(hidden ? [`+${hidden}`] : [])].join(separator);
239
+ };
240
+ updateBadges();
241
+ const line = () => items.map((item) => item.text).filter(Boolean).join(separator);
242
+ const overflow = () => visibleWidth(line()) - width;
243
+ const progress = items.find((item) => item.name === "progress");
244
+ if (progress && overflow() > 0) {
245
+ const currentWidth = visibleWidth(progress.text);
246
+ // Keep a short activity fragment while trying the other compact forms.
247
+ const minimumWidth = Math.min(12, width);
248
+ const progressWidth = Math.min(currentWidth, Math.max(minimumWidth, currentWidth - overflow()));
249
+ progress.text = truncateToWidth(progress.text, progressWidth, "…");
250
+ }
251
+ // Keep the roomy default while progress alone can absorb width pressure.
252
+ // Tighten gaps before compacting paths/context or overflowing whole badges.
253
+ if (overflow() > 0) {
254
+ separator = ` ${divider} `;
255
+ updateBadges();
256
+ }
257
+ for (const name of ["cwd", "context", "thinking"] as const) {
258
+ const item = items.find((item) => item.name === name);
259
+ if (!item) continue;
260
+ for (const alternative of item.alternatives ?? []) {
261
+ if (overflow() <= 0) break;
262
+ if (visibleWidth(alternative) < visibleWidth(item.text)) item.text = alternative;
263
+ }
264
+ }
265
+ while (extensions && shownBadges > 0 && overflow() > 0) {
266
+ shownBadges--;
267
+ updateBadges();
268
+ }
269
+ // On very narrow terminals, remove optional content before clipping identity.
270
+ for (const name of ["progress", "cwd", "extensions"] as const) {
271
+ if (overflow() <= 0) break;
272
+ const item = items.find((item) => item.name === name);
273
+ if (item) {
274
+ item.text = name === "cwd"
275
+ ? truncateToWidth(item.text, Math.max(0, visibleWidth(item.text) - overflow()), "…")
276
+ : "";
277
+ }
278
+ }
279
+ const model = items.find((item) => item.name === "model");
280
+ if (model && overflow() > 0) {
281
+ model.text = truncateToWidth(model.text, Math.max(0, visibleWidth(model.text) - overflow()), "…");
282
+ }
283
+ // Whole-badge overflow can free more space than needed. Use that space rather
284
+ // than leaving unnecessarily compact context, paths, or progress on screen.
285
+ for (const name of ["context", "thinking", "cwd", "progress"] as const) {
286
+ const item = items.find((item) => item.name === name);
287
+ const original = segments.find((segment) => segment.name === name);
288
+ if (!item?.text || !original) continue;
289
+ const budget = visibleWidth(item.text) - overflow();
290
+ if (name === "progress") {
291
+ item.text = truncateToWidth(original.text, Math.max(0, budget), "…");
292
+ } else {
293
+ const preferred = [original.text, ...(original.alternatives ?? [])]
294
+ .find((text) => visibleWidth(text) <= budget);
295
+ if (preferred !== undefined) item.text = preferred;
296
+ }
297
+ }
298
+ // A terminal narrower than thinking + context cannot show every core value.
299
+ return truncateToWidth(line(), width, "…");
300
+ }
301
+
198
302
  function thinkingColor(level: string): ThemeColor {
199
303
  switch (level) {
200
304
  case "off":
@@ -1566,7 +1670,7 @@ function shouldShowStatus(key: string, filter: StatusFilter): boolean {
1566
1670
  return !filter.hidden.has(key);
1567
1671
  }
1568
1672
 
1569
- function formatExtensionStatuses(
1673
+ export function formatExtensionStatuses(
1570
1674
  statuses: ReadonlyMap<string, string>,
1571
1675
  filter: StatusFilter,
1572
1676
  seenStatusKeys: Set<string>,
@@ -1577,9 +1681,7 @@ function formatExtensionStatuses(
1577
1681
  seenStatusKeys.add(key);
1578
1682
  return shouldShowStatus(key, filter);
1579
1683
  })
1580
- .map(([key, text]) =>
1581
- `${stripTerminalControls(key)}:${stripTerminalControls(text)}`,
1582
- );
1684
+ .map(([, text]) => stripTerminalControls(text));
1583
1685
 
1584
1686
  return parts.length > 0 ? parts : null;
1585
1687
  }
@@ -1699,6 +1801,9 @@ export default function (pi: ExtensionAPI) {
1699
1801
  let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
1700
1802
  let visibleSegments: SegmentName[] = readGlobalSegments() ?? DEFAULT_SEGMENTS;
1701
1803
  const seenStatusKeys = new Set<string>();
1804
+ let currentStatuses: ReadonlyMap<string, string> = new Map();
1805
+ const statusDescription = (key: string) =>
1806
+ stripTerminalControls(currentStatuses.get(key) ?? "") || "No current status text";
1702
1807
  const refresh = () => requestRender?.();
1703
1808
  const progress = new FooterProgressEngine(refresh);
1704
1809
  const restoreStatusFilter = (ctx: ExtensionContext) => {
@@ -1783,8 +1888,8 @@ export default function (pi: ExtensionAPI) {
1783
1888
  },
1784
1889
  ...knownStatusKeys.map((key): SettingItem => ({
1785
1890
  id: `status:${key}`,
1786
- label: `Status: ${key}`,
1787
- description: "Extension status visibility",
1891
+ label: `Status: ${stripTerminalControls(key)}`,
1892
+ description: statusDescription(key),
1788
1893
  currentValue: statusVisibility.get(key) ? "shown" : "hidden",
1789
1894
  values: ["shown", "hidden"],
1790
1895
  })),
@@ -1899,8 +2004,8 @@ export default function (pi: ExtensionAPI) {
1899
2004
  },
1900
2005
  ...knownStatusKeys.map((key): SettingItem => ({
1901
2006
  id: key,
1902
- label: key,
1903
- description: "Extension status visibility",
2007
+ label: stripTerminalControls(key),
2008
+ description: statusDescription(key),
1904
2009
  currentValue: statusVisibility.get(key) ? "shown" : "hidden",
1905
2010
  values: ["shown", "hidden"],
1906
2011
  })),
@@ -2134,8 +2239,9 @@ export default function (pi: ExtensionAPI) {
2134
2239
  render(width: number): string[] {
2135
2240
  const modelName = formatModelName(ctx.model?.id);
2136
2241
  const thinkingLevel = String(pi.getThinkingLevel());
2242
+ currentStatuses = footerData?.getExtensionStatuses?.() ?? new Map();
2137
2243
  const extensionStatusParts = formatExtensionStatuses(
2138
- footerData?.getExtensionStatuses?.() ?? new Map(),
2244
+ currentStatuses,
2139
2245
  statusFilter,
2140
2246
  seenStatusKeys,
2141
2247
  );
@@ -2151,29 +2257,33 @@ export default function (pi: ExtensionAPI) {
2151
2257
  : "—";
2152
2258
  const progressText = progress.text();
2153
2259
 
2154
- const extensionStatuses = extensionStatusParts
2155
- ? extensionStatusParts
2156
- .map((part) => theme.fg("text", part))
2157
- .join(` ${theme.fg("dim", EXTENSION_STATUS_SEPARATOR)} `)
2158
- : null;
2159
- const segmentRenderers: Record<SegmentName, string | null> = {
2160
- model: theme.fg("accent", modelName),
2161
- thinking: theme.fg(thinkingColor(thinkingLevel), `think:${thinkingLevel}`),
2162
- context: theme.fg(contextSegmentColor, contextText),
2163
- cwd: visibleSegments.includes("cwd")
2164
- ? theme.fg("accent", formatCwd(ctx.cwd, homedir(), Math.min(cwdWidth, width)))
2165
- : null,
2166
- progress: progressText ? theme.fg("text", progressText) : null,
2167
- extensions: extensionStatuses,
2260
+ const directories = visibleSegments.includes("cwd")
2261
+ ? cwdVariants(ctx.cwd, homedir(), cwdWidth).map((text) => theme.fg("accent", text))
2262
+ : [];
2263
+ const contextCompact = usage
2264
+ ? usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "—%"
2265
+ : "—";
2266
+ const shortThinking = thinkingLevel === "medium" ? "med" : thinkingLevel === "minimal" ? "min" : thinkingLevel;
2267
+ const renderers: Record<SegmentName, Omit<FooterSegment, "name">> = {
2268
+ model: { text: theme.fg("accent", stripTerminalControls(modelName)) },
2269
+ thinking: {
2270
+ text: theme.fg(thinkingColor(thinkingLevel), `think:${thinkingLevel}`),
2271
+ alternatives: [theme.fg(thinkingColor(thinkingLevel), `think:${shortThinking}`)],
2272
+ },
2273
+ context: {
2274
+ text: theme.fg(contextSegmentColor, contextText),
2275
+ alternatives: [theme.fg(contextSegmentColor, contextCompact)],
2276
+ },
2277
+ cwd: { text: directories[0] ?? "", alternatives: directories.slice(1) },
2278
+ progress: { text: progressText ? theme.fg("text", progressText) : "" },
2279
+ extensions: { text: "" },
2168
2280
  };
2169
-
2170
- const separator = ` ${theme.fg("dim", SEGMENT_SEPARATOR)} `;
2171
- const line = visibleSegments
2172
- .map((segment) => segmentRenderers[segment])
2173
- .filter((segment): segment is string => segment !== null)
2174
- .join(separator);
2175
-
2176
- return [truncateToWidth(line, width)];
2281
+ return [layoutFooter(
2282
+ visibleSegments.map((name) => ({ name, ...renderers[name] })),
2283
+ (extensionStatusParts ?? []).map((part) => theme.fg("text", part)),
2284
+ width,
2285
+ theme.fg("dim", SEGMENT_SEPARATOR),
2286
+ )];
2177
2287
  },
2178
2288
  };
2179
2289
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bar",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
4
4
  "description": "Pi footer/statusline showing active model, thinking level, context pressure, live progress updates, and extension statuses.",
5
5
  "type": "module",
6
6
  "keywords": [