@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 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
+ [![CI](https://github.com/erlinerd/pi-footer-wrap/actions/workflows/ci.yml/badge.svg)](https://github.com/erlinerd/pi-footer-wrap/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
@@ -0,0 +1,50 @@
1
+ # pi-footer-wrap
2
+
3
+ [![CI](https://github.com/erlinerd/pi-footer-wrap/actions/workflows/ci.yml/badge.svg)](https://github.com/erlinerd/pi-footer-wrap/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
+ }