@bismawy/pi-arnative 0.2.11 → 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,10 @@
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
+
3
8
  ## [0.2.11] - 2026-10-01
4
9
 
5
10
  ### Changed
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
@@ -177,59 +178,64 @@ export function boxEditorLines(
177
178
  }
178
179
 
179
180
  /**
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.
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.
188
187
  */
189
- export function layoutStatusLines(
190
- groups: readonly string[],
191
- metrics: string,
192
- sep: string,
188
+ export function layoutFooterGrid(
189
+ headLeft: string,
190
+ headRight: string,
191
+ left: readonly string[],
192
+ right: readonly string[],
193
+ leftSep: string,
194
+ rightSep: string,
193
195
  width: number,
194
- rows: number,
195
- maxRows = Number.MAX_SAFE_INTEGER,
196
196
  vw: (s: string) => number = visibleWidth,
197
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;
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
+ }
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));
212
224
  }
225
+ return out;
226
+ }
213
227
 
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
- }
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;
229
235
  }
230
-
231
- out.push(line);
232
- return out;
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);
233
239
  }
234
240
 
235
241
  function parseOptimizer(raw: string | undefined): string | null {
@@ -458,8 +464,6 @@ export default function (pi: ExtensionAPI) {
458
464
  left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
459
465
  }
460
466
  const right1 = formatModelName(currentModel, currentThinkingLevel, acc, tint, dim);
461
- const pad1 = " ".repeat(Math.max(1, width - visibleWidth(left1) - visibleWidth(right1)));
462
- const lines = [truncateToWidth(left1 + pad1 + right1, width)];
463
467
 
464
468
  const statuses: ReadonlyMap<string, string> = (() => {
465
469
  try {
@@ -521,19 +525,11 @@ export default function (pi: ExtensionAPI) {
521
525
  // fallback
522
526
  }
523
527
  const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${tint(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
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
- }
535
- }
536
- return lines;
528
+ const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${tint(opt)}` : "", usageStr].filter(Boolean);
529
+
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);
537
533
  },
538
534
  };
539
535
  });
@@ -724,26 +720,28 @@ if (isMain(import.meta.url)) {
724
720
  assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
725
721
  assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
726
722
 
727
- // Self-check: footer status wrapping (2 lines until it crowds, then 3).
728
- const vw = (s: string) => s.length;
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;
729
727
  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");
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");
747
745
 
748
746
  const plain = (s: string) => s;
749
747
  const D = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; // 8 columns = content width in this test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.2.11",
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",