@pi-archimedes/footer 2.7.3 → 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 +2 -2
- package/src/index.ts +11 -3
- package/src/utils/format.test.ts +54 -0
- package/src/utils/format.ts +124 -0
- package/src/utils/render-smoke.test.ts +34 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/footer",
|
|
3
|
-
"version": "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.
|
|
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
|
-
//
|
|
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
|
package/src/utils/format.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
});
|