@bismawy/pi-arnative 0.2.10 → 0.2.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.11] - 2026-10-01
4
+
5
+ ### Changed
6
+ - The footer status area now reflows: 2 lines stay the default while everything fits, but on a narrow terminal the statuses and the metrics (tok/s, cache, context) move onto a 3rd line instead of being truncated. `extensions/footer.ts` `layoutStatusLines()` (`rows` = line budget of the whole footer, `maxRows` caps the growth); self-check covers the wide/narrow/cap cases.
7
+
3
8
  ## [0.2.10] - 2026-10-01
4
9
 
5
10
  ### Added
@@ -176,7 +176,64 @@ export function boxEditorLines(
176
176
  return out;
177
177
  }
178
178
 
179
+ /**
180
+ * Wraps the footer status lines: `groups` separated by `sep`, with the already-joined
181
+ * `metrics` right-aligned on the last group line when they fit, else on a line of
182
+ * their own.
183
+ *
184
+ * `rows` is the footer line budget (line 1 plus the status lines): 2 reproduces the
185
+ * classic layout as long as it fits, 3 lets the metrics take their own line instead
186
+ * of crowding the statuses. `maxRows` caps the growth — a group wider than the
187
+ * terminal is never split, the caller truncates it.
188
+ */
189
+ export function layoutStatusLines(
190
+ groups: readonly string[],
191
+ metrics: string,
192
+ sep: string,
193
+ width: number,
194
+ rows: number,
195
+ maxRows = Number.MAX_SAFE_INTEGER,
196
+ vw: (s: string) => number = visibleWidth,
197
+ ): string[] {
198
+ if (rows > maxRows) rows = maxRows;
199
+ const budget = Math.max(1, rows - 1);
200
+ const out: string[] = [];
201
+ let line = "";
202
+
203
+ for (const group of groups) {
204
+ const candidate = line ? line + sep + group : group;
205
+ // An empty line is never flushed: an oversized group must not loop here.
206
+ if (line && vw(candidate) > width) {
207
+ out.push(line);
208
+ line = group;
209
+ } else {
210
+ line = candidate;
211
+ }
212
+ }
213
+
214
+ if (metrics) {
215
+ const pad = (before: string) => " ".repeat(Math.max(1, width - vw(before) - vw(metrics))) + metrics;
216
+ if (!line) {
217
+ line = pad("");
218
+ } else if (vw(line + sep + metrics) <= width) {
219
+ // Metrics share the last group line, right-aligned as before.
220
+ line = line + pad(line);
221
+ } else if (out.length < budget) {
222
+ // `budget` is the only thing `rows` controls: 2 lets the metrics take a
223
+ // line of their own instead of crowding the groups, 1 packs everything.
224
+ out.push(line);
225
+ line = pad("");
226
+ } else {
227
+ line = line + sep + metrics;
228
+ }
229
+ }
230
+
231
+ out.push(line);
232
+ return out;
233
+ }
234
+
179
235
  function parseOptimizer(raw: string | undefined): string | null {
236
+
180
237
  if (!raw) return null;
181
238
  const m = raw.match(/([A-Za-z0-9_-]+)\s+cache\s+(\d+\/\d+)·[^\s]+\s+([\d.]+%)/);
182
239
  return m ? `${m[1]} ${m[2]} (${m[3]})` : null;
@@ -412,7 +469,6 @@ export default function (pi: ExtensionAPI) {
412
469
  }
413
470
  })();
414
471
  const rawCache = statuses.get("pi-cache-stats");
415
- const segs: string[] = [];
416
472
  const cleanStatus = (s: string) => {
417
473
  if (s.includes("MCP:")) {
418
474
  let clean = stripAnsi(s);
@@ -450,12 +506,12 @@ export default function (pi: ExtensionAPI) {
450
506
  return tint(clean.trim());
451
507
  };
452
508
 
509
+ const groups: string[] = [];
453
510
  const mcp = statuses.get("mcp");
454
- if (mcp !== undefined) segs.push(cleanStatus(mcp));
511
+ if (mcp !== undefined) groups.push(cleanStatus(mcp));
455
512
  for (const [k, s] of statuses) {
456
- if (k !== "mcp" && k !== "pi-cache-stats") segs.push(cleanStatus(s));
513
+ if (k !== "mcp" && k !== "pi-cache-stats") groups.push(cleanStatus(s));
457
514
  }
458
- const left2 = segs.join(sep);
459
515
 
460
516
  const opt = parseOptimizer(rawCache);
461
517
  let usageStr = "";
@@ -465,13 +521,17 @@ export default function (pi: ExtensionAPI) {
465
521
  // fallback
466
522
  }
467
523
  const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${tint(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
468
- const right2 = [speedStr, opt ? `${acc("\udb80\udf5b")} ${tint(opt)}` : "", usageStr].filter(Boolean).join(` ${dim("·")} `);
469
-
470
- if (!right2) {
471
- lines.push(truncateToWidth(left2, width));
472
- } else {
473
- const pad2 = " ".repeat(Math.max(1, width - visibleWidth(left2) - visibleWidth(right2)));
474
- lines.push(truncateToWidth(left2 + pad2 + right2, width));
524
+ const metrics = [speedStr, opt ? `${acc("\udb80\udf5b")} ${tint(opt)}` : "", usageStr].filter(Boolean).join(` ${dim("·")} `);
525
+
526
+ // 2 footer lines normally; grow to 3 rather than crowding the second one on
527
+ // a narrow terminal, then truncate (a single status can still overflow).
528
+ const MAX_ROWS = 3;
529
+ for (let rows = 2; ; rows++) {
530
+ const wrapped = layoutStatusLines(groups, metrics, sep, width, rows, MAX_ROWS);
531
+ if (rows >= MAX_ROWS || wrapped.every((l) => visibleWidth(l) <= width)) {
532
+ for (const l of wrapped) lines.push(truncateToWidth(l, width));
533
+ break;
534
+ }
475
535
  }
476
536
  return lines;
477
537
  },
@@ -664,6 +724,27 @@ if (isMain(import.meta.url)) {
664
724
  assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
665
725
  assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
666
726
 
727
+ // Self-check: footer status wrapping (2 lines until it crowds, then 3).
728
+ const vw = (s: string) => s.length;
729
+ const g = (n: number) => "g".repeat(n);
730
+ // Wide terminal: everything on one status line, metrics right-aligned.
731
+ const wide = layoutStatusLines([g(4), g(4), g(4)], "Mmmmm", " | ", 40, 2, 3, vw);
732
+ assert(wide.length === 1, "wide: statuses share one line");
733
+ assert(wide[0]!.endsWith("Mmmmm") && vw(wide[0]!) === 40, "wide: metrics right-aligned on that line");
734
+ // Narrow: groups stop sharing a line and metrics moves to its own third line.
735
+ const narrow = layoutStatusLines([g(4), g(4), g(4)], g(8), " | ", 14, 3, 3, vw);
736
+ assert(narrow.length === 3, "narrow: grows to 3 lines");
737
+ assert(narrow.every((l) => vw(l) <= 14), "narrow: every line fits the terminal");
738
+ // The same width with a 2-line budget cannot fit -> the caller asks for row 3.
739
+ assert(
740
+ layoutStatusLines([g(4), g(4), g(4)], g(8), " | ", 14, 2, 3, vw).some((l) => vw(l) > 14),
741
+ "2-line budget overflows, so the caller grows to 3"
742
+ );
743
+ // Tight cap: never exceed maxRows, even though lines then overflow (caller truncates).
744
+ assert(layoutStatusLines([g(4), g(4), g(4)], "Mmmmm", " | ", 3, 3, 3, vw).length <= 3, "cap: 3 lines at most");
745
+ assert(layoutStatusLines([g(9)], "", " | ", 4, 2, 3, vw).length === 1, "oversized group is not split");
746
+ assert(layoutStatusLines([], "m", " | ", 8, 2, 3, vw).length === 1, "metrics only");
747
+
667
748
  const plain = (s: string) => s;
668
749
  const D = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; // 8 columns = content width in this test
669
750
  const boxed = boxEditorLines([D, " hi ", " lo ", D, " /mo "], 2, plain);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "Arnative look for Pi: warm-neutral themes, rounded tool boxes, tabbed header, compact footer, transcript clock, and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",