@pi-archimedes/footer 2.7.2 → 2.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "2.7.2",
3
+ "version": "2.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,7 +19,7 @@
19
19
  "./config": "./src/config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.7.2"
22
+ "@pi-archimedes/core": "2.8.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * dir | branch [+status] | worktree | model | ◕thinking | ↑↓R W $cost ━ context%
2
+ * dir | branch [+status] | worktree | model | ◕thinking · ⚠ext-status | ↑↓R W $cost ━ context%
3
3
  *
4
4
  * Adaptive layout — wraps to additional lines instead of truncating:
5
5
  * - fits width → single line (system info · stats · context bar)
6
6
  * - doesn't fit → two lines (system info above, stats + bar below)
7
7
  * - left sections alone overflow → three or more lines
8
8
  * - below splitThreshold (default 150) → at least two lines, as configured
9
+ * - oversized extension statuses are word-wrapped into multiple chunks so no
10
+ * content is ever truncated by clampLine
9
11
  */
10
12
 
11
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -15,7 +17,7 @@ import { loadFooterConfig } from "./config.js";
15
17
  import { CostAccumulator } from "./cost-accumulator.js";
16
18
  import { getGitStatus, isInsideLinkedWorktree } from "./utils/git.js";
17
19
  import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
18
- import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount } from "./utils/format.js";
20
+ import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount, wrapStatusToChunks } from "./utils/format.js";
19
21
  import { footerIcons } from "./utils/icons.js";
20
22
  import { packFooterLines, SEP_W, SEPARATOR } from "./utils/layout.js";
21
23
 
@@ -76,12 +78,18 @@ export function registerFooter(pi: ExtensionAPI): void {
76
78
 
77
79
  const branchIcon = inWorktree ? footerIcons.worktree : footerIcons.branch;
78
80
 
79
- // System info sections: dir | branch [+status] | model | thinking
81
+ // Extension status texts (pi setStatus) — may contain ANSI; oversized statuses wrap
82
+ const extensionStatusChunks = [...footerData.getExtensionStatuses().values()]
83
+ .filter(Boolean)
84
+ .flatMap((s) => wrapStatusToChunks(s, width));
85
+
86
+ // System info sections: dir | branch [+status] | model | thinking | ext-statuses
80
87
  const leftSections = [
81
88
  colorize("syntaxFunction", " " + footerIcons.directory + currentDirectory),
82
89
  currentBranch ? colorize("success", branchIcon + " " + currentBranch + (gitStatusStr ? " " + gitStatusStr : "")) : "",
83
90
  colorize("syntaxType", footerIcons.model + " " + activeModel),
84
91
  thinkingIndicatorStr,
92
+ ...extensionStatusChunks,
85
93
  ].filter(Boolean);
86
94
 
87
95
  // Usage stats: ↑in ↓out RcacheRead WcacheWrite $cost contextWindow
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { stripAnsi } from "@pi-archimedes/core/text";
3
4
  import {
4
5
  formatTokenCount,
5
6
  formatContextBar,
6
7
  formatGitStatusIndicators,
7
8
  formatThinkingIndicator,
9
+ wrapStatusToChunks,
8
10
  } from "./format.js";
9
11
  import { thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
10
12
 
@@ -161,3 +163,55 @@ describe("formatThinkingIndicator", () => {
161
163
  expect(formatThinkingIndicator("unknown", mockColor)).toBe("◑ unknown");
162
164
  });
163
165
  });
166
+
167
+ // ── wrapStatusToChunks ──────────────────────────────────────────────────────
168
+
169
+ describe("wrapStatusToChunks", () => {
170
+ it("short status fits → returned unchanged (including ANSI)", () => {
171
+ const status = "\u001b[33m⚠ token invalid\u001b[0m";
172
+ const chunks = wrapStatusToChunks(status, 80);
173
+ expect(chunks).toEqual([status]);
174
+ });
175
+
176
+ it("empty status → [status]", () => {
177
+ expect(wrapStatusToChunks("", 80)).toEqual([""]);
178
+ expect(wrapStatusToChunks(" ", 80)).toEqual([" "]);
179
+ });
180
+
181
+ it("oversized status wraps: no content loss, every chunk ≤ width", () => {
182
+ const colored = "\u001b[33m⚠\u001b[0m";
183
+ // Build a ~40-word status with an ANSI-colored prefix
184
+ const words = Array.from({ length: 40 }, (_, i) => `word${i}`);
185
+ const status = colored + " " + words.join(" ");
186
+ const width = 40;
187
+ const chunks = wrapStatusToChunks(status, width);
188
+ // Every chunk must fit in width
189
+ expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
190
+ // No content loss — normalise whitespace in stripped versions
191
+ const joined = chunks.map(stripAnsi).join(" ").replace(/\s+/g, " ").trim();
192
+ const original = stripAnsi(status).replace(/\s+/g, " ").trim();
193
+ expect(joined).toBe(original);
194
+ });
195
+
196
+ it("oversized single word (URL) splits without loss", () => {
197
+ const url = "https://" + "a".repeat(192);
198
+ const width = 30;
199
+ const chunks = wrapStatusToChunks(url, width);
200
+ // Concat of stripped chunks equals stripped original
201
+ expect(chunks.map(stripAnsi).join("")).toBe(stripAnsi(url));
202
+ // Every chunk fits
203
+ expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
204
+ });
205
+
206
+ it("word-wider-than-width preserves ANSI — concatenated raw string equals input", () => {
207
+ const longWord = "\u001b[31m" + "Z".repeat(60) + "\u001b[0m";
208
+ const width = 20;
209
+ const chunks = wrapStatusToChunks(longWord, width);
210
+ // Some chunk must contain the opening escape
211
+ expect(chunks.some((c) => c.includes("\u001b[31m"))).toBe(true);
212
+ // Concatenation of raw chunks (no spaces) equals the input
213
+ expect(chunks.join("")).toBe(longWord);
214
+ // No chunk exceeds width
215
+ expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
216
+ });
217
+ });
@@ -1,3 +1,4 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
1
2
  import { footerIcons, gitDisplayIcons, gitStatusColors, thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
2
3
 
3
4
  // Token counts use binary units (1K = 1024)
@@ -52,3 +53,126 @@ export function formatGitStatusIndicators(
52
53
  export function formatThinkingIndicator(thinkingLevel: string, colorize: ColorFn): string {
53
54
  return colorize(thinkingLevelColors[thinkingLevel] || "dim", `${thinkingLevelIcons[thinkingLevel] || "◑"} ${thinkingLevel}`);
54
55
  }
56
+
57
+ /**
58
+ * Wrap an extension status into chunks that fit the terminal.
59
+ * - fits (visibleWidth ≤ width) → [status] (unchanged, including ANSI)
60
+ * - oversized → greedy word-wrap: whole words packed per chunk; a single word
61
+ * wider than `width` is split at character level on the raw string (ANSI
62
+ * sequences stay attached to the chars they prefix because we split on plain
63
+ * whitespace only — never mid-escape).
64
+ * No content is ever dropped; every returned chunk has visibleWidth ≤ width.
65
+ */
66
+ export function wrapStatusToChunks(status: string, width: number): string[] {
67
+ // Edge cases: empty/whitespace or non-positive width → return as-is
68
+ if (width <= 0 || !status.trim()) return [status];
69
+ // Already fits → return unchanged (preserves ANSI)
70
+ if (visibleWidth(status) <= width) return [status];
71
+
72
+ // Split on whitespace boundaries only — never inside ANSI escapes
73
+ // (ANSI escapes contain no plain whitespace, so splitting on /\s+/ is safe)
74
+ const words = status.split(/(\s+)/);
75
+ // words alternates: [word, sep, word, sep, ...]
76
+ // We reassemble non-sep tokens and carry sep tokens to preserve spacing context
77
+ // Simpler: collect [token, isSep] pairs
78
+ const tokens: Array<{ text: string; isSep: boolean }> = [];
79
+ for (const w of words) {
80
+ if (w === "") continue;
81
+ tokens.push({ text: w, isSep: /^\s+$/.test(w) });
82
+ }
83
+
84
+ const chunks: string[] = [];
85
+ let current = "";
86
+ let currentW = 0;
87
+
88
+ /**
89
+ * Split a single word (no whitespace) that is wider than `width` into
90
+ * sub-chunks of at most `width` visible columns, character by character.
91
+ * ANSI escapes are kept intact (we only split printable characters;
92
+ * the regex /\x1b\[[^m]*m/ never contains whitespace, so they were
93
+ * never split by the outer word-split, and here we advance char by char
94
+ * through the raw string, which keeps escape bytes together with their
95
+ * surrounding characters as long as we don't split inside an escape).
96
+ *
97
+ * Implementation: iterate character-by-character through the raw string,
98
+ * accumulating into a sub-chunk until adding the next char would exceed width.
99
+ */
100
+ function splitWideWord(word: string): string[] {
101
+ const result: string[] = [];
102
+ let sub = "";
103
+ let subW = 0;
104
+ let i = 0;
105
+ while (i < word.length) {
106
+ // Detect ANSI escape sequence starting at i (\x1b[...m)
107
+ // An escape contributes 0 visible width, so we append it whole.
108
+ if (word[i] === "\x1b") {
109
+ const end = word.indexOf("m", i + 1);
110
+ if (end !== -1) {
111
+ sub += word.slice(i, end + 1);
112
+ i = end + 1;
113
+ continue;
114
+ }
115
+ }
116
+ // Regular character (may be multi-byte Unicode)
117
+ const char = word[i]!;
118
+ const charW = visibleWidth(char);
119
+ if (subW + charW > width && sub !== "") {
120
+ result.push(sub);
121
+ sub = char;
122
+ subW = charW;
123
+ } else {
124
+ sub += char;
125
+ subW += charW;
126
+ }
127
+ i++;
128
+ }
129
+ if (sub !== "") result.push(sub);
130
+ return result.length > 0 ? result : [word];
131
+ }
132
+
133
+ function flushCurrent() {
134
+ if (current !== "") {
135
+ chunks.push(current);
136
+ current = "";
137
+ currentW = 0;
138
+ }
139
+ }
140
+
141
+ for (const token of tokens) {
142
+ if (token.isSep) {
143
+ // Separator — skip (we rebuild spacing implicitly via chunks)
144
+ continue;
145
+ }
146
+ const word = token.text;
147
+ const wordW = visibleWidth(word);
148
+
149
+ if (wordW > width) {
150
+ // Word is wider than the entire terminal — flush then char-split it
151
+ flushCurrent();
152
+ const subChunks = splitWideWord(word);
153
+ for (let j = 0; j < subChunks.length - 1; j++) {
154
+ chunks.push(subChunks[j]!);
155
+ }
156
+ // Last sub-chunk becomes the start of the next current
157
+ const last = subChunks[subChunks.length - 1]!;
158
+ current = last;
159
+ currentW = visibleWidth(last);
160
+ } else if (currentW === 0) {
161
+ // Starting a new chunk
162
+ current = word;
163
+ currentW = wordW;
164
+ } else if (currentW + 1 + wordW <= width) {
165
+ // Append with a single space
166
+ current += " " + word;
167
+ currentW += 1 + wordW;
168
+ } else {
169
+ // Doesn't fit — flush and start new chunk
170
+ flushCurrent();
171
+ current = word;
172
+ currentW = wordW;
173
+ }
174
+ }
175
+ flushCurrent();
176
+
177
+ return chunks.length > 0 ? chunks : [status];
178
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { stripAnsi } from "@pi-archimedes/core/text";
3
3
  import { packFooterLines, SEP_W, SEPARATOR } from "./layout.js";
4
- import { formatContextBar } from "./format.js";
4
+ import { formatContextBar, wrapStatusToChunks } from "./format.js";
5
5
  import { visibleWidth } from "@earendil-works/pi-tui";
6
6
 
7
7
  // Minimal stand-ins: same visible chars as the real footer, no ANSI
@@ -14,13 +14,14 @@ const MODEL = "🧠 claude-sonnet-4-6";
14
14
  const THINKING = "◕ high"; // current format for the 'high' level
15
15
  const STATS = "↑141 ↓42k R8.7M W220k $4.25 116k/977k";
16
16
 
17
- function buildLines(width: number, pct: number, splitThreshold = 150) {
17
+ function buildLines(width: number, pct: number, splitThreshold = 150, extraSections: string[] = []) {
18
18
  const leftSections = [
19
19
  DIR,
20
20
  BRANCH,
21
21
  WORKTREE,
22
22
  MODEL,
23
23
  THINKING,
24
+ ...extraSections,
24
25
  ].filter(Boolean);
25
26
  let groups = packFooterLines([...leftSections, STATS], width, SEP_W);
26
27
  if (groups.length < 2 && width < splitThreshold && leftSections.length > 0) {
@@ -84,4 +85,35 @@ describe("render glue simulation", () => {
84
85
  // No line exceeds 40 visible columns
85
86
  expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 40)).toBe(true);
86
87
  });
88
+
89
+ it("extension status chunk wraps without truncation on the user's terminal", () => {
90
+ const STATUS = "⚠ litellm token invalid — re-auth required";
91
+ const lines = buildLines(158, 12, 150, [STATUS]);
92
+ const stripped = lines.map((l) => stripAnsi(l));
93
+ const all = stripped.join(" ");
94
+ // (a) status text is fully present across joined lines (no clipping)
95
+ expect(all).toContain(STATUS);
96
+ // (b) no line exceeds 158 visible columns
97
+ expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 158)).toBe(true);
98
+ });
99
+
100
+ it("oversized extension status wraps through the production path without truncation", () => {
101
+ const LONG =
102
+ "⚠ litellm token invalid — re-auth required: " +
103
+ "gcloud auth application-default login (see https://support.google.com/a/answer/9368756 for details) — check Settings → Auth for more context " +
104
+ "and verify your credentials are up-to-date before retrying the long operation that failed";
105
+ // Verify it is >158 visible chars
106
+ expect(visibleWidth(LONG)).toBeGreaterThan(158);
107
+ const chunks = wrapStatusToChunks(LONG, 158);
108
+ const lines = buildLines(158, 12, 150, chunks);
109
+ const stripped = lines.map((l) => stripAnsi(l));
110
+ // (a) every word of the original status appears in the joined lines
111
+ const allText = stripped.join(" ");
112
+ const words = stripAnsi(LONG).split(/\s+/).filter(Boolean);
113
+ for (const word of words) {
114
+ expect(allText).toContain(word);
115
+ }
116
+ // (b) every line ≤ 158 visible cols
117
+ expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 158)).toBe(true);
118
+ });
87
119
  });