@erlin-ai/pi-footer-wrap 0.1.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/README.zh-CN.md +50 -0
- package/lib/footer.mjs.del +170 -0
- package/lib/footer.ts +211 -0
- package/package.json +53 -0
- package/src/index.ts +139 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Takes over the pi footer and re-renders the exact default footer content:
|
|
13
|
+
cwd/branch/session-name, token stats (input/output/cache/cache-hit/cost/
|
|
14
|
+
context%) with right-aligned model + thinking level, and all extension
|
|
15
|
+
statuses.
|
|
16
|
+
- Adaptive wrapping: statuses wrap across as many lines as needed (breaking
|
|
17
|
+
between statuses first, hard-wrapping single over-wide statuses) — nothing
|
|
18
|
+
is truncated with "…", unlike the default footer.
|
|
19
|
+
- `/footer-wrap` command to toggle back to pi's built-in footer at any time.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 erlinerd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# pi-footer-wrap
|
|
2
|
+
|
|
3
|
+
[](https://github.com/erlinerd/pi-footer-wrap/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
English | [中文](README.zh-CN.md)
|
|
7
|
+
|
|
8
|
+
**Adaptive-wrapping footer for the pi coding agent.** Takes over the default footer with exactly one behavior change: **wrap when it doesn't fit — never truncate**.
|
|
9
|
+
|
|
10
|
+
The default footer squeezes all extension statuses onto a single line and cuts them off with `…` when the terminal is too narrow — with many extensions installed, data silently disappears. This plugin re-renders the same three lines:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
~/code/xxx (main) • session name
|
|
14
|
+
↑12k ↓3.4k R45k W1k CH88.8% $0.123 45.6%/200k (auto) glm-5.3-flash • max
|
|
15
|
+
45.2 tok/s · mcp: 8 tools ready · lf: tracing on · …
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Line 1**: cwd (`~` abbreviated), git branch, session name
|
|
19
|
+
- **Line 2**: cumulative ↑input ↓output, R/W cache, cache-hit rate, cost, context usage (recolors past 70%/90%), model name + thinking level on the right
|
|
20
|
+
- **Line 3+**: **all** extension statuses, sorted by key; wraps onto new lines when out of width, hard-wraps single over-wide statuses — nothing is dropped
|
|
21
|
+
|
|
22
|
+
Data sources and formatting mirror the pi 0.85.x default footer field by field (`sessionManager` entries, `getContextUsage`, `ctx.thinkingLevel`, `PI_EXPERIMENTAL`, the kimi `(sub)` marker, …) — only the wrapping strategy differs.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install github:erlinerd/pi-footer-wrap # GitHub
|
|
28
|
+
pi install npm:@erlin-ai/pi-footer-wrap # npm
|
|
29
|
+
pi install /path/to/pi-footer-wrap # local development
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Takes effect in new sessions.
|
|
33
|
+
|
|
34
|
+
## Toggle
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
/footer-wrap # switch back to pi's default footer; run again to re-enable
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install
|
|
44
|
+
npm test # tsx --test
|
|
45
|
+
npx tsc --noEmit
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# pi-footer-wrap
|
|
2
|
+
|
|
3
|
+
[](https://github.com/erlinerd/pi-footer-wrap/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
[English](README.md) | 中文
|
|
7
|
+
|
|
8
|
+
pi coding agent 的**自适应换行 footer**。接管 pi 默认 footer,唯一的行为差异:**放不下就换行,绝不截断**。
|
|
9
|
+
|
|
10
|
+
pi 默认 footer 把所有扩展状态挤在一行,超出终端宽度就截断加 `…`——装了多个扩展后数据会悄悄丢失。本插件完整复刻默认 footer 的全部三行内容:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
~/code/xxx (main) • 会话名
|
|
14
|
+
↑12k ↓3.4k R45k W1k CH88.8% $0.123 45.6%/200k (auto) glm-5.3-flash • max
|
|
15
|
+
45.2 tok/s · mcp: 8 tools ready · lf: tracing on · …
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **第 1 行**:目录(`~` 缩写)、git 分支、会话名
|
|
19
|
+
- **第 2 行**:累计 ↑输入 ↓输出 R/W 缓存、缓存命中率、成本、上下文占用(超 70%/90% 变色)、右侧模型名 + thinking 档位
|
|
20
|
+
- **第 3 行起**:**所有**扩展状态,按 key 字母序,装不下自动换行;单条超宽的字内硬换行——一个字都不少
|
|
21
|
+
|
|
22
|
+
数据来源与格式逐字段对齐 pi 0.85.x 默认 footer(`sessionManager` entries、`getContextUsage`、`ctx.thinkingLevel`、`PI_EXPERIMENTAL`、kimi `(sub)` 标记等),差异只有换行策略。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install github:erlinerd/pi-footer-wrap # GitHub
|
|
28
|
+
pi install npm:@erlin-ai/pi-footer-wrap # npm
|
|
29
|
+
pi install /path/to/pi-footer-wrap # 本地开发
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
新开会话即生效。
|
|
33
|
+
|
|
34
|
+
## 切换
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
/footer-wrap # 随时切回 pi 默认 footer;再敲一次切回来
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 开发
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install
|
|
44
|
+
npm test # tsx --test
|
|
45
|
+
npx tsc --noEmit
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// pi-footer-wrap — pure footer layout/format helpers (no pi runtime imports).
|
|
2
|
+
// Mirrors pi 0.85.x default footer formatting (dist/modes/interactive/components/footer.js)
|
|
3
|
+
// so the wrapped footer shows the SAME data, minus the "truncate with …" behavior.
|
|
4
|
+
|
|
5
|
+
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
/** Same token compaction as the pi default footer. */
|
|
8
|
+
export function formatTokens(count) {
|
|
9
|
+
if (count < 1000) return count.toString();
|
|
10
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
11
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
12
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
13
|
+
return `${Math.round(count / 1000000)}M`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Replace $HOME with ~ like the default footer. */
|
|
17
|
+
export function formatCwdForFooter(cwd, home) {
|
|
18
|
+
if (!home) return cwd;
|
|
19
|
+
const resolvedHome = home.endsWith("/") ? home.slice(0, -1) : home;
|
|
20
|
+
if (cwd === resolvedHome) return "~";
|
|
21
|
+
if (cwd.startsWith(resolvedHome + "/"))
|
|
22
|
+
return "~" + cwd.slice(resolvedHome.length);
|
|
23
|
+
return cwd;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Collapse newlines/tabs/multi-spaces (statuses must stay line-safe). */
|
|
27
|
+
export function sanitizeStatusText(text) {
|
|
28
|
+
return String(text)
|
|
29
|
+
.replace(/[\r\n\t]/g, " ")
|
|
30
|
+
.replace(/ +/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sum usage across session entries the way the default footer does:
|
|
36
|
+
* assistant messages, toolResult messages carrying usage, branch_summary and
|
|
37
|
+
* compaction entries. Also tracks the latest cache-hit rate (per assistant turn).
|
|
38
|
+
*/
|
|
39
|
+
export function collectUsage(entries) {
|
|
40
|
+
const t = {
|
|
41
|
+
input: 0,
|
|
42
|
+
output: 0,
|
|
43
|
+
cacheRead: 0,
|
|
44
|
+
cacheWrite: 0,
|
|
45
|
+
cost: 0,
|
|
46
|
+
latestCacheHitRate: undefined,
|
|
47
|
+
};
|
|
48
|
+
for (const e of entries ?? []) {
|
|
49
|
+
const u = e?.message?.usage ?? e?.usage;
|
|
50
|
+
if (!u) continue;
|
|
51
|
+
t.input += u.input || 0;
|
|
52
|
+
t.output += u.output || 0;
|
|
53
|
+
t.cacheRead += u.cacheRead || 0;
|
|
54
|
+
t.cacheWrite += u.cacheWrite || 0;
|
|
55
|
+
t.cost += u.cost?.total || 0;
|
|
56
|
+
// Cache-hit rate tracks the latest ASSISTANT prompt only (matches pi:
|
|
57
|
+
// compaction/branch_summary usage must not overwrite it).
|
|
58
|
+
if (e?.message?.role === "assistant") {
|
|
59
|
+
const prompt = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
|
|
60
|
+
if (prompt > 0)
|
|
61
|
+
t.latestCacheHitRate = ((u.cacheRead || 0) / prompt) * 100;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return t;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stats parts, in the default footer's order and format.
|
|
69
|
+
* @param {object} opts
|
|
70
|
+
* @param {object} opts.totals
|
|
71
|
+
* @param {number} opts.ctxPercent
|
|
72
|
+
* @param {number} opts.contextWindow
|
|
73
|
+
* @param {boolean} [opts.autoCompact=true]
|
|
74
|
+
* @param {boolean} [opts.subscription=false]
|
|
75
|
+
* @param {string|null} [opts.experimentalBadge]
|
|
76
|
+
* @param {(text: string, level?: string) => string} [opts.colorize]
|
|
77
|
+
*/
|
|
78
|
+
export function buildStatsParts({
|
|
79
|
+
totals,
|
|
80
|
+
ctxPercent,
|
|
81
|
+
contextWindow,
|
|
82
|
+
autoCompact = true,
|
|
83
|
+
subscription = false,
|
|
84
|
+
experimentalBadge = null,
|
|
85
|
+
colorize,
|
|
86
|
+
}) {
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (totals.input) parts.push(`↑${formatTokens(totals.input)}`);
|
|
89
|
+
if (totals.output) parts.push(`↓${formatTokens(totals.output)}`);
|
|
90
|
+
if (totals.cacheRead) parts.push(`R${formatTokens(totals.cacheRead)}`);
|
|
91
|
+
if (totals.cacheWrite) parts.push(`W${formatTokens(totals.cacheWrite)}`);
|
|
92
|
+
if (
|
|
93
|
+
(totals.cacheRead > 0 || totals.cacheWrite > 0) &&
|
|
94
|
+
totals.latestCacheHitRate !== undefined
|
|
95
|
+
) {
|
|
96
|
+
parts.push(`CH${totals.latestCacheHitRate.toFixed(1)}%`);
|
|
97
|
+
}
|
|
98
|
+
if (totals.cost || subscription) {
|
|
99
|
+
parts.push(`$${totals.cost.toFixed(3)}${subscription ? " (sub)" : ""}`);
|
|
100
|
+
}
|
|
101
|
+
const auto = autoCompact ? " (auto)" : "";
|
|
102
|
+
const pctText = `${ctxPercent.toFixed(1)}%/${formatTokens(contextWindow)}${auto}`;
|
|
103
|
+
const level =
|
|
104
|
+
ctxPercent > 90 ? "error" : ctxPercent > 70 ? "warning" : undefined;
|
|
105
|
+
parts.push(colorize ? colorize(pctText, level) : pctText);
|
|
106
|
+
if (experimentalBadge) parts.push(experimentalBadge);
|
|
107
|
+
return parts;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Stats line with right-aligned model — same layout as the default footer,
|
|
112
|
+
* but instead of truncating, the model drops to its own right-aligned line,
|
|
113
|
+
* and in ultra-narrow terminals both parts hard-wrap. No data is dropped.
|
|
114
|
+
*/
|
|
115
|
+
export function layoutStats(parts, right, width, minPad = 2) {
|
|
116
|
+
const left = parts.join(" ");
|
|
117
|
+
const lw = visibleWidth(left);
|
|
118
|
+
const rw = visibleWidth(right);
|
|
119
|
+
if (lw + minPad + rw <= width) {
|
|
120
|
+
return [left + " ".repeat(width - lw - rw) + right];
|
|
121
|
+
}
|
|
122
|
+
const wrap = (s) =>
|
|
123
|
+
visibleWidth(s) <= width
|
|
124
|
+
? [s]
|
|
125
|
+
: String(wrapTextWithAnsi(s, width)).split("\n");
|
|
126
|
+
if (lw <= width || rw <= width) {
|
|
127
|
+
const leftLines = lw <= width ? [left] : wrap(left);
|
|
128
|
+
const rightLines =
|
|
129
|
+
rw <= width ? [" ".repeat(Math.max(1, width - rw)) + right] : wrap(right);
|
|
130
|
+
return [...leftLines, ...rightLines];
|
|
131
|
+
}
|
|
132
|
+
return [...wrap(left), ...wrap(right)];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pack extension statuses onto as few lines as fit, breaking BETWEEN statuses
|
|
137
|
+
* first; a single status wider than the terminal is hard-wrapped. Nothing is
|
|
138
|
+
* ever truncated or dropped (unlike the default footer's "…").
|
|
139
|
+
*/
|
|
140
|
+
export function wrapStatuses(statuses, width, sep = " · ") {
|
|
141
|
+
const lines = [];
|
|
142
|
+
let cur = "";
|
|
143
|
+
const sepW = visibleWidth(sep);
|
|
144
|
+
for (const raw of statuses ?? []) {
|
|
145
|
+
const s = String(raw);
|
|
146
|
+
if (!s) continue;
|
|
147
|
+
if (visibleWidth(s) > width) {
|
|
148
|
+
if (cur) {
|
|
149
|
+
lines.push(cur);
|
|
150
|
+
cur = "";
|
|
151
|
+
}
|
|
152
|
+
const wrapped = wrapTextWithAnsi(s, width);
|
|
153
|
+
const pieces = Array.isArray(wrapped)
|
|
154
|
+
? wrapped
|
|
155
|
+
: String(wrapped).split("\n");
|
|
156
|
+
for (const piece of pieces) lines.push(piece);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!cur) {
|
|
160
|
+
cur = s;
|
|
161
|
+
} else if (visibleWidth(cur) + sepW + visibleWidth(s) <= width) {
|
|
162
|
+
cur += sep + s;
|
|
163
|
+
} else {
|
|
164
|
+
lines.push(cur);
|
|
165
|
+
cur = s;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (cur) lines.push(cur);
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
package/lib/footer.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// pi-footer-wrap — pure footer layout/format helpers (no pi runtime imports).
|
|
2
|
+
// Mirrors pi 0.85.x default footer formatting (dist/modes/interactive/components/footer.js)
|
|
3
|
+
// so the wrapped footer shows the SAME data, minus the "truncate with …" behavior.
|
|
4
|
+
|
|
5
|
+
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
/** Loose shape of the usage object pi attaches to messages/entries. */
|
|
8
|
+
export interface UsageLike {
|
|
9
|
+
input?: number;
|
|
10
|
+
output?: number;
|
|
11
|
+
cacheRead?: number;
|
|
12
|
+
cacheWrite?: number;
|
|
13
|
+
totalTokens?: number;
|
|
14
|
+
cost?: { total?: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Loose shape of a pi session entry we care about (pi entries carry arbitrary extra fields). */
|
|
18
|
+
export interface SessionEntryLike {
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
message?: { role?: string; usage?: UsageLike };
|
|
21
|
+
usage?: UsageLike;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UsageTotals {
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
cacheRead: number;
|
|
28
|
+
cacheWrite: number;
|
|
29
|
+
cost: number;
|
|
30
|
+
latestCacheHitRate: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildStatsPartsOptions {
|
|
34
|
+
totals: UsageTotals;
|
|
35
|
+
ctxPercent: number;
|
|
36
|
+
contextWindow: number;
|
|
37
|
+
autoCompact?: boolean;
|
|
38
|
+
subscription?: boolean;
|
|
39
|
+
experimentalBadge?: string | null;
|
|
40
|
+
colorize?: (text: string, level?: "warning" | "error") => string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Same token compaction as the pi default footer. */
|
|
44
|
+
export function formatTokens(count: number): string {
|
|
45
|
+
if (count < 1000) return count.toString();
|
|
46
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
47
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
48
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
49
|
+
return `${Math.round(count / 1000000)}M`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Replace $HOME with ~ like the default footer. */
|
|
53
|
+
export function formatCwdForFooter(cwd: string, home: string): string {
|
|
54
|
+
if (!home) return cwd;
|
|
55
|
+
const resolvedHome = home.endsWith("/") ? home.slice(0, -1) : home;
|
|
56
|
+
if (cwd === resolvedHome) return "~";
|
|
57
|
+
if (cwd.startsWith(resolvedHome + "/"))
|
|
58
|
+
return "~" + cwd.slice(resolvedHome.length);
|
|
59
|
+
return cwd;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Collapse newlines/tabs/multi-spaces (statuses must stay line-safe). */
|
|
63
|
+
export function sanitizeStatusText(text: unknown): string {
|
|
64
|
+
return String(text)
|
|
65
|
+
.replace(/[\r\n\t]/g, " ")
|
|
66
|
+
.replace(/ +/g, " ")
|
|
67
|
+
.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sum usage across session entries the way the default footer does:
|
|
72
|
+
* assistant messages, toolResult messages carrying usage, branch_summary and
|
|
73
|
+
* compaction entries. Also tracks the latest cache-hit rate (per assistant turn).
|
|
74
|
+
*/
|
|
75
|
+
export function collectUsage(entries?: SessionEntryLike[] | null): UsageTotals {
|
|
76
|
+
const t: UsageTotals = {
|
|
77
|
+
input: 0,
|
|
78
|
+
output: 0,
|
|
79
|
+
cacheRead: 0,
|
|
80
|
+
cacheWrite: 0,
|
|
81
|
+
cost: 0,
|
|
82
|
+
latestCacheHitRate: undefined,
|
|
83
|
+
};
|
|
84
|
+
for (const e of entries ?? []) {
|
|
85
|
+
const u: UsageLike | undefined = e?.message?.usage ?? e?.usage;
|
|
86
|
+
if (!u) continue;
|
|
87
|
+
t.input += u.input || 0;
|
|
88
|
+
t.output += u.output || 0;
|
|
89
|
+
t.cacheRead += u.cacheRead || 0;
|
|
90
|
+
t.cacheWrite += u.cacheWrite || 0;
|
|
91
|
+
t.cost += u.cost?.total || 0;
|
|
92
|
+
// Cache-hit rate tracks the latest ASSISTANT prompt only (matches pi:
|
|
93
|
+
// compaction/branch_summary usage must not overwrite it).
|
|
94
|
+
if (e?.message?.role === "assistant") {
|
|
95
|
+
const prompt = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
|
|
96
|
+
if (prompt > 0)
|
|
97
|
+
t.latestCacheHitRate = ((u.cacheRead || 0) / prompt) * 100;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return t;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stats parts, in the default footer's order and format.
|
|
105
|
+
* `colorize` applies theme colors (level: "warning" | "error");
|
|
106
|
+
* `subscription` adds the Kimi-style " (sub)" cost marker;
|
|
107
|
+
* `experimentalBadge` is a pre-colored "• xp" string, or null.
|
|
108
|
+
*/
|
|
109
|
+
export function buildStatsParts({
|
|
110
|
+
totals,
|
|
111
|
+
ctxPercent,
|
|
112
|
+
contextWindow,
|
|
113
|
+
autoCompact = true,
|
|
114
|
+
subscription = false,
|
|
115
|
+
experimentalBadge = null,
|
|
116
|
+
colorize,
|
|
117
|
+
}: BuildStatsPartsOptions): string[] {
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
if (totals.input) parts.push(`↑${formatTokens(totals.input)}`);
|
|
120
|
+
if (totals.output) parts.push(`↓${formatTokens(totals.output)}`);
|
|
121
|
+
if (totals.cacheRead) parts.push(`R${formatTokens(totals.cacheRead)}`);
|
|
122
|
+
if (totals.cacheWrite) parts.push(`W${formatTokens(totals.cacheWrite)}`);
|
|
123
|
+
if (
|
|
124
|
+
(totals.cacheRead > 0 || totals.cacheWrite > 0) &&
|
|
125
|
+
totals.latestCacheHitRate !== undefined
|
|
126
|
+
) {
|
|
127
|
+
parts.push(`CH${totals.latestCacheHitRate.toFixed(1)}%`);
|
|
128
|
+
}
|
|
129
|
+
if (totals.cost || subscription) {
|
|
130
|
+
parts.push(`$${totals.cost.toFixed(3)}${subscription ? " (sub)" : ""}`);
|
|
131
|
+
}
|
|
132
|
+
const auto = autoCompact ? " (auto)" : "";
|
|
133
|
+
const pctText = `${ctxPercent.toFixed(1)}%/${formatTokens(contextWindow)}${auto}`;
|
|
134
|
+
const level: "error" | "warning" | undefined =
|
|
135
|
+
ctxPercent > 90 ? "error" : ctxPercent > 70 ? "warning" : undefined;
|
|
136
|
+
parts.push(colorize ? colorize(pctText, level) : pctText);
|
|
137
|
+
if (experimentalBadge) parts.push(experimentalBadge);
|
|
138
|
+
return parts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Stats line with right-aligned model — same layout as the default footer,
|
|
143
|
+
* but instead of truncating, the model drops to its own right-aligned line,
|
|
144
|
+
* and in ultra-narrow terminals both parts hard-wrap. No data is dropped.
|
|
145
|
+
*/
|
|
146
|
+
export function layoutStats(
|
|
147
|
+
parts: string[],
|
|
148
|
+
right: string,
|
|
149
|
+
width: number,
|
|
150
|
+
minPad = 2,
|
|
151
|
+
): string[] {
|
|
152
|
+
const left = parts.join(" ");
|
|
153
|
+
const lw = visibleWidth(left);
|
|
154
|
+
const rw = visibleWidth(right);
|
|
155
|
+
if (lw + minPad + rw <= width) {
|
|
156
|
+
return [left + " ".repeat(width - lw - rw) + right];
|
|
157
|
+
}
|
|
158
|
+
const wrap = (s: string): string[] =>
|
|
159
|
+
visibleWidth(s) <= width
|
|
160
|
+
? [s]
|
|
161
|
+
: String(wrapTextWithAnsi(s, width)).split("\n");
|
|
162
|
+
if (lw <= width || rw <= width) {
|
|
163
|
+
const leftLines = lw <= width ? [left] : wrap(left);
|
|
164
|
+
const rightLines =
|
|
165
|
+
rw <= width ? [" ".repeat(Math.max(1, width - rw)) + right] : wrap(right);
|
|
166
|
+
return [...leftLines, ...rightLines];
|
|
167
|
+
}
|
|
168
|
+
return [...wrap(left), ...wrap(right)];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Pack extension statuses onto as few lines as fit, breaking BETWEEN statuses
|
|
173
|
+
* first; a single status wider than the terminal is hard-wrapped. Nothing is
|
|
174
|
+
* ever truncated or dropped (unlike the default footer's "…").
|
|
175
|
+
* `sep` may contain ANSI escapes — packing math uses its visible width.
|
|
176
|
+
*/
|
|
177
|
+
export function wrapStatuses(
|
|
178
|
+
statuses: Iterable<string> | null | undefined,
|
|
179
|
+
width: number,
|
|
180
|
+
sep = " · ",
|
|
181
|
+
): string[] {
|
|
182
|
+
const lines: string[] = [];
|
|
183
|
+
let cur = "";
|
|
184
|
+
const sepW = visibleWidth(sep);
|
|
185
|
+
for (const raw of statuses ?? []) {
|
|
186
|
+
const s = String(raw);
|
|
187
|
+
if (!s) continue;
|
|
188
|
+
if (visibleWidth(s) > width) {
|
|
189
|
+
if (cur) {
|
|
190
|
+
lines.push(cur);
|
|
191
|
+
cur = "";
|
|
192
|
+
}
|
|
193
|
+
const wrapped = wrapTextWithAnsi(s, width);
|
|
194
|
+
const pieces = Array.isArray(wrapped)
|
|
195
|
+
? wrapped
|
|
196
|
+
: String(wrapped).split("\n");
|
|
197
|
+
for (const piece of pieces) lines.push(piece);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!cur) {
|
|
201
|
+
cur = s;
|
|
202
|
+
} else if (visibleWidth(cur) + sepW + visibleWidth(s) <= width) {
|
|
203
|
+
cur += sep + s;
|
|
204
|
+
} else {
|
|
205
|
+
lines.push(cur);
|
|
206
|
+
cur = s;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (cur) lines.push(cur);
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@erlin-ai/pi-footer-wrap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Adaptive-wrapping footer for the pi coding agent: takes over the default footer and wraps extension statuses across lines instead of truncating them. No data is dropped.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"lib",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"CHANGELOG.md"
|
|
12
|
+
],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./src/index.ts"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "tsx --test test/*.test.ts"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pi-package",
|
|
26
|
+
"pi",
|
|
27
|
+
"pi-coding-agent",
|
|
28
|
+
"extension",
|
|
29
|
+
"footer",
|
|
30
|
+
"wrap"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"author": "erlinerd",
|
|
34
|
+
"homepage": "https://github.com/erlinerd/pi-footer-wrap#readme",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/erlinerd/pi-footer-wrap.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/erlinerd/pi-footer-wrap/issues"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^26.6.2",
|
|
44
|
+
"tsx": "^4.23.14",
|
|
45
|
+
"typescript": "^7.0.2"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-tui": "*"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-footer-wrap — takes over the pi footer and wraps instead of truncating.
|
|
3
|
+
*
|
|
4
|
+
* pi's default footer joins ALL extension statuses on one line and cuts it
|
|
5
|
+
* off with "…" when it doesn't fit. With many extensions installed, data is
|
|
6
|
+
* silently lost. This plugin reproduces the default footer's three lines
|
|
7
|
+
* (pwd/branch/session-name, token stats + model, extension statuses) but
|
|
8
|
+
* wraps every line adaptively — nothing is dropped.
|
|
9
|
+
*
|
|
10
|
+
* /footer-wrap toggles back to pi's default footer at any time.
|
|
11
|
+
*/
|
|
12
|
+
import { wrapTextWithAnsi, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import {
|
|
14
|
+
buildStatsParts,
|
|
15
|
+
collectUsage,
|
|
16
|
+
formatCwdForFooter,
|
|
17
|
+
layoutStats,
|
|
18
|
+
sanitizeStatusText,
|
|
19
|
+
wrapStatuses,
|
|
20
|
+
} from "../lib/footer";
|
|
21
|
+
|
|
22
|
+
function wrapToLines(text: string, width: number): string[] {
|
|
23
|
+
if (visibleWidth(text) <= width) return [text];
|
|
24
|
+
const wrapped = wrapTextWithAnsi(text, width);
|
|
25
|
+
return Array.isArray(wrapped) ? wrapped : String(wrapped).split("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function (pi: any) {
|
|
29
|
+
let enabled = true;
|
|
30
|
+
|
|
31
|
+
function renderFooter(ctx: any, theme: any, footerData: any, width: number) {
|
|
32
|
+
const colorize = (text: string, level?: string) =>
|
|
33
|
+
level === "error"
|
|
34
|
+
? theme.fg("error", text)
|
|
35
|
+
: level === "warning"
|
|
36
|
+
? theme.fg("warning", text)
|
|
37
|
+
: text;
|
|
38
|
+
|
|
39
|
+
// Line 1 — cwd (branch) • session name (same format as default)
|
|
40
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
41
|
+
const cwd = ctx.sessionManager?.getCwd?.() || process.cwd();
|
|
42
|
+
let pwd = formatCwdForFooter(cwd, home);
|
|
43
|
+
const branch = footerData?.getGitBranch?.() ?? null;
|
|
44
|
+
if (branch) pwd += ` (${branch})`;
|
|
45
|
+
const sessionName = ctx.sessionManager?.getSessionName?.();
|
|
46
|
+
if (sessionName) pwd += ` • ${sessionName}`;
|
|
47
|
+
|
|
48
|
+
// Line 2 — ↑in ↓out R… W… CH…% $cost ctx%/window (auto) + model right
|
|
49
|
+
const totals = collectUsage(ctx.sessionManager?.getEntries?.() ?? []);
|
|
50
|
+
const cu = ctx.getContextUsage?.() ?? null;
|
|
51
|
+
const subscription = ctx.model?.provider === "kimi-coding";
|
|
52
|
+
const experimentalBadge =
|
|
53
|
+
process.env.PI_EXPERIMENTAL === "1"
|
|
54
|
+
? `${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`
|
|
55
|
+
: null;
|
|
56
|
+
const parts = buildStatsParts({
|
|
57
|
+
totals,
|
|
58
|
+
ctxPercent: cu?.percent ?? 0,
|
|
59
|
+
contextWindow: cu?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
60
|
+
autoCompact: ctx.session?.autoCompactionEnabled ?? true,
|
|
61
|
+
subscription,
|
|
62
|
+
experimentalBadge,
|
|
63
|
+
colorize,
|
|
64
|
+
});
|
|
65
|
+
const model = ctx.model;
|
|
66
|
+
let right = model?.id || "no-model";
|
|
67
|
+
if (model?.reasoning) {
|
|
68
|
+
const level = ctx.thinkingLevel || "off";
|
|
69
|
+
right =
|
|
70
|
+
level === "off" ? `${right} • thinking off` : `${right} • ${level}`;
|
|
71
|
+
}
|
|
72
|
+
if ((footerData?.getAvailableProviderCount?.() ?? 1) > 1 && model) {
|
|
73
|
+
right = `(${model.provider}) ${right}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Lines 3+ — every extension status, sorted by key (like default), wrapped
|
|
77
|
+
let statuses: string[] = [];
|
|
78
|
+
try {
|
|
79
|
+
statuses = [...(footerData?.getExtensionStatuses?.() ?? [])]
|
|
80
|
+
.sort(([a], [b]) => String(a).localeCompare(String(b)))
|
|
81
|
+
.map(([, text]) => sanitizeStatusText(text as string))
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
} catch {
|
|
84
|
+
// statuses are best-effort
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
...wrapToLines(pwd, width),
|
|
89
|
+
...layoutStats(parts, right, width),
|
|
90
|
+
...wrapStatuses(statuses, width, theme.fg("dim", " · ")),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function enable(ctx: any) {
|
|
95
|
+
try {
|
|
96
|
+
ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
|
|
97
|
+
let dispose = () => {};
|
|
98
|
+
try {
|
|
99
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
100
|
+
if (typeof unsub === "function") dispose = unsub;
|
|
101
|
+
} catch {
|
|
102
|
+
// branch reactivity is optional
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
dispose,
|
|
106
|
+
invalidate() {},
|
|
107
|
+
render(w: number) {
|
|
108
|
+
return renderFooter(ctx, theme, footerData, w);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
// non-TUI mode — ignore
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
118
|
+
if (enabled) enable(ctx);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pi.registerCommand("footer-wrap", {
|
|
122
|
+
description:
|
|
123
|
+
"Toggle pi-footer-wrap (adaptive wrapping, no truncation) vs pi default footer",
|
|
124
|
+
handler: async (_args: any, ctx: any) => {
|
|
125
|
+
enabled = !enabled;
|
|
126
|
+
if (enabled) {
|
|
127
|
+
enable(ctx);
|
|
128
|
+
ctx.ui.notify("footer-wrap on — full statuses, wrapped", "info");
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
ctx.ui.setFooter(undefined);
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
ctx.ui.notify("pi default footer restored", "info");
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|