@bismawy/pi-arnative 0.2.10 → 0.2.12

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.12] - 2026-10-02
4
+
5
+ ### Changed
6
+ - The footer is a fixed 3-row grid: row 1 pairs the cwd line with the model, rows 2-3 pair the extension status (mcp first) with the metrics (tok/s · cache · token) — each side wraps inside its half of the terminal, the right cells stay right-aligned, and a row where both sides ran out is dropped. Replaces the adaptive 2→3 reflow; no click toggle (`extensions/footer.ts`, `layoutFooterGrid()` + `pairRow()`).
7
+
8
+ ## [0.2.11] - 2026-10-01
9
+
10
+ ### Changed
11
+ - 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.
12
+
3
13
  ## [0.2.10] - 2026-10-01
4
14
 
5
15
  ### Added
package/README.md CHANGED
@@ -15,7 +15,7 @@ pi-arnative replaces pi’s default terminal chrome with a unified, cohesive vis
15
15
  - Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
16
16
  - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, Themes, and Shortcut.
17
17
  - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call.
18
- - Enhanced Footer: 2-line layout showing directory path, git branch, model, and token metrics.
18
+ - Enhanced Footer: fixed 3-row grid — directory/git line, then extension status paired with token metrics.
19
19
  - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
20
20
  - Usage Dashboard: /usage command to track tokens and session costs.
21
21
 
@@ -68,7 +68,7 @@ Add to `~/.pi/agent/keybindings.json` to bind `app.session.new`:
68
68
  | --- | --- |
69
69
  | `extensions/tools.ts` | Rounded tool boxes, spinners, and duration |
70
70
  | `extensions/section-headers.ts` | Tabbed header and resource box |
71
- | `extensions/footer.ts` | 2-line status footer and boxed editor |
71
+ | `extensions/footer.ts` | 3-row status grid footer and boxed editor |
72
72
  | `extensions/timestamps.ts` | Message clock and bubble background fixes |
73
73
  | `extensions/ui-render-tweaks.ts` | Contrast tweaks, selection style, and UI polish |
74
74
  | `extensions/usage.ts` | `/usage` token dashboard |
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Arnative footer (forced to 2 lines) + custom editor.
3
- * Line 1: cwd | duration | branch/tag/status ...... model + thinking level.
4
- * Line 2: other extension status (mcp first) ...... tok/s · cache · token.
2
+ * Arnative footer (fixed 3-row grid, no click toggle).
3
+ * Row 1: cwd | duration | branch/tag/status ...... model + thinking level.
4
+ * Rows 2-3: extension status (mcp first) ......... tok/s · cache · token,
5
+ * each side wrapped inside its half of the width and paired row by row.
5
6
  *
6
7
  * Transcript clock & bubble bg: extensions/timestamps.ts
7
8
  * /new header: extensions/section-headers.ts
@@ -176,7 +177,69 @@ export function boxEditorLines(
176
177
  return out;
177
178
  }
178
179
 
180
+ /**
181
+ * The footer as a fixed 3-row grid. Row 1 pairs the head cells (cwd line, model);
182
+ * rows 2-3 pair the wrapped left cells (extension status, mcp first) with the
183
+ * wrapped right cells (tok/s · cache · token), each side wrapping inside its half
184
+ * of the terminal so the columns never collide. Right cells stay right-aligned.
185
+ * A side that runs out of cells leaves its half blank, and a row where both sides
186
+ * ran out is dropped, so a quiet footer stays 1-3 rows tall.
187
+ */
188
+ export function layoutFooterGrid(
189
+ headLeft: string,
190
+ headRight: string,
191
+ left: readonly string[],
192
+ right: readonly string[],
193
+ leftSep: string,
194
+ rightSep: string,
195
+ width: number,
196
+ vw: (s: string) => number = visibleWidth,
197
+ ): string[] {
198
+ const half = Math.max(1, Math.floor(width / 2) - 1);
199
+ // Wrap each side at half width; the first break happens at most once (rows 2-3),
200
+ // after that the rest stays on the last row and the pairing truncates it.
201
+ const wrap = (cells: readonly string[], sep: string): string[] => {
202
+ const rows: string[] = [];
203
+ let line = "";
204
+ for (const cell of cells) {
205
+ const candidate = line ? line + sep + cell : cell;
206
+ if (line && rows.length === 0 && vw(candidate) > half) {
207
+ rows.push(line);
208
+ line = cell;
209
+ } else {
210
+ line = candidate;
211
+ }
212
+ }
213
+ if (line) rows.push(line);
214
+ return rows;
215
+ };
216
+ const leftRows = wrap(left, leftSep);
217
+ const rightRows = wrap(right, rightSep);
218
+ const out = [pairRow(headLeft, headRight, width, vw)];
219
+ for (let i = 0; i < Math.max(leftRows.length, rightRows.length); i++) {
220
+ const l = leftRows[i];
221
+ const r = rightRows[i];
222
+ if (l === undefined && r === undefined) break;
223
+ out.push(pairRow(l ?? "", r ?? "", width, vw));
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /** One grid row: `left` at the start, `right` flush to the right edge. */
229
+ function pairRow(left: string, right: string, width: number, vw: (s: string) => number): string {
230
+ if (!right) return truncateToWidth(left, width);
231
+ const rightWidth = Math.min(vw(right), width);
232
+ if (!left) return " ".repeat(width - rightWidth) + truncateToWidth(right, width);
233
+ if (vw(left) + 1 + rightWidth <= width) {
234
+ return left + " ".repeat(width - vw(left) - rightWidth) + right;
235
+ }
236
+ // Too wide together: the right cell keeps its columns, the left one is cut.
237
+ const room = Math.max(1, width - rightWidth - 1);
238
+ return truncateToWidth(left, room) + " " + truncateToWidth(right, width - room - 1);
239
+ }
240
+
179
241
  function parseOptimizer(raw: string | undefined): string | null {
242
+
180
243
  if (!raw) return null;
181
244
  const m = raw.match(/([A-Za-z0-9_-]+)\s+cache\s+(\d+\/\d+)·[^\s]+\s+([\d.]+%)/);
182
245
  return m ? `${m[1]} ${m[2]} (${m[3]})` : null;
@@ -401,8 +464,6 @@ export default function (pi: ExtensionAPI) {
401
464
  left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
402
465
  }
403
466
  const right1 = formatModelName(currentModel, currentThinkingLevel, acc, tint, dim);
404
- const pad1 = " ".repeat(Math.max(1, width - visibleWidth(left1) - visibleWidth(right1)));
405
- const lines = [truncateToWidth(left1 + pad1 + right1, width)];
406
467
 
407
468
  const statuses: ReadonlyMap<string, string> = (() => {
408
469
  try {
@@ -412,7 +473,6 @@ export default function (pi: ExtensionAPI) {
412
473
  }
413
474
  })();
414
475
  const rawCache = statuses.get("pi-cache-stats");
415
- const segs: string[] = [];
416
476
  const cleanStatus = (s: string) => {
417
477
  if (s.includes("MCP:")) {
418
478
  let clean = stripAnsi(s);
@@ -450,12 +510,12 @@ export default function (pi: ExtensionAPI) {
450
510
  return tint(clean.trim());
451
511
  };
452
512
 
513
+ const groups: string[] = [];
453
514
  const mcp = statuses.get("mcp");
454
- if (mcp !== undefined) segs.push(cleanStatus(mcp));
515
+ if (mcp !== undefined) groups.push(cleanStatus(mcp));
455
516
  for (const [k, s] of statuses) {
456
- if (k !== "mcp" && k !== "pi-cache-stats") segs.push(cleanStatus(s));
517
+ if (k !== "mcp" && k !== "pi-cache-stats") groups.push(cleanStatus(s));
457
518
  }
458
- const left2 = segs.join(sep);
459
519
 
460
520
  const opt = parseOptimizer(rawCache);
461
521
  let usageStr = "";
@@ -465,15 +525,11 @@ export default function (pi: ExtensionAPI) {
465
525
  // fallback
466
526
  }
467
527
  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("·")} `);
528
+ const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${tint(opt)}` : "", usageStr].filter(Boolean);
469
529
 
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));
475
- }
476
- return lines;
530
+ // Fixed 3-row grid: head (cwd/model), then statuses and metrics each
531
+ // wrapped inside their half of the width — no click toggle.
532
+ return layoutFooterGrid(left1, right1, groups, metricCells, sep, ` ${dim("·")} `, width);
477
533
  },
478
534
  };
479
535
  });
@@ -664,6 +720,29 @@ if (isMain(import.meta.url)) {
664
720
  assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
665
721
  assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
666
722
 
723
+ // Self-check: the footer grid — head row, then statuses/metrics paired per row.
724
+ // truncateToWidth appends ANSI resets, so measure through stripAnsi (the real
725
+ // renderer measures with visibleWidth, which ignores them the same way).
726
+ const vw = (s: string) => stripAnsi(s).length;
727
+ const g = (n: number) => "g".repeat(n);
728
+ // Head row: cwd line left, model right-aligned; groups and metrics pair below.
729
+ const grid = layoutFooterGrid("H".repeat(10), "M".repeat(6), [g(10), g(10), g(10)], ["s".repeat(10), "u".repeat(10)], " | ", " · ", 40, vw);
730
+ assert(grid.length === 3, "grid: head + two paired rows");
731
+ assert(grid[0]!.startsWith("H".repeat(10)) && grid[0]!.endsWith("M".repeat(6)), "grid: head pairs cwd and model");
732
+ assert(grid[0]!.length === 40, "grid: head right-aligns the model");
733
+ assert(grid[1]!.includes("g") && grid[1]!.endsWith("s".repeat(10)), "grid: row 2 pairs statuses and metrics");
734
+ assert(grid.every((l) => vw(l) <= 40), "grid: every row fits the terminal");
735
+ // Each side wraps inside its half: 3 groups + 2 metrics at width 20 -> 3 rows.
736
+ const tight = layoutFooterGrid("h", "m", [g(6), g(6), g(6)], [g(6), g(6)], " | ", " · ", 20, vw);
737
+ assert(tight.length === 3, "grid: wraps inside the half width");
738
+ assert(tight.every((l) => vw(l) <= 20), "grid: wrapped rows still fit");
739
+ // The right cell survives an oversized left cell (the left one is truncated).
740
+ const cut = layoutFooterGrid("h", "m", [g(30)], ["R".repeat(4)], " | ", " · ", 12, vw);
741
+ assert(cut[1]!.endsWith("R".repeat(4)) && vw(cut[1]!) === 12, "grid: right cell survives a wide left cell");
742
+ // A side that runs out leaves its half blank; rows where both ran out drop.
743
+ const blank = layoutFooterGrid("h", "m", [], [], " | ", " · ", 20, vw);
744
+ assert(blank.length === 1, "grid: empty sides drop their rows");
745
+
667
746
  const plain = (s: string) => s;
668
747
  const D = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; // 8 columns = content width in this test
669
748
  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.12",
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",