pi-tidy-footer 0.4.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,121 @@
1
+ # Changelog
2
+
3
+ [中文](#中文)
4
+
5
+ ## [0.4.0] - 2026-07-22
6
+
7
+ ### Added
8
+
9
+ - `/se` command: show or set extension status sort order, persisted across sessions
10
+ - Extension statuses now sorted by user-defined order instead of alphabetically; unlisted extensions appear at the end
11
+
12
+ ### Changed
13
+
14
+ - Ponytail and Caveman status formatting switched from regex reconstruction to replace-based approach, preserving native colours
15
+ - Ponytail status: removed ⚡ emoji, level text lowercased, 🐴 emoji kept
16
+ - Caveman status: label capitalised to `Caveman:`, level text lowercased
17
+
18
+ ### Fixed
19
+
20
+ - `writePersistedEnabled` no longer overwrites other state keys (balance thresholds, extension order)
21
+ - Duplicate `clearTimeout` call in `dispose()` removed
22
+ - Unreachable fallback in `formatToolActivity` removed
23
+ - `!== null` → `!= null` in context usage check (handles undefined correctly)
24
+ - `fetchBalance` for DeepSeek: added null guard before `parseFloat` to avoid implicit exception handling
25
+ - Removed redundant `existsSync` calls in `readPersistedEnabled` and `readExtensionOrder`
26
+
27
+ ## [0.3.0] — 2026-07-19
28
+
29
+ ### Added
30
+
31
+ - Account balance after the model name on line 2 (`¤¥` format, DeepSeek and Kimi), auto-refreshed after each turn (30s cooldown); yellow under warn threshold, red under alert threshold
32
+ - `/bt <warn> <alert>` command to set custom yellow/red balance thresholds, persisted, warn > alert required
33
+
34
+ ### Changed
35
+
36
+ - Tool activity indicator simplified to show only the running tool with a brief hold for fast tools; removed thinking and completed states
37
+ - README: repositioned cost removal as "from the left stats area, keeping it token-focused"; added "Tweaks to other extensions" section
38
+
39
+ ## [0.2.0] — 2026-07-19
40
+
41
+ ### Added
42
+
43
+ - Git status tokens next to the branch name on line 1: `⇡` ahead, `⇣` behind, `+` staged, `~` modified, `?` untracked, `!` conflicts; hidden when clean; fetched asynchronously (250ms debounce + 30s polling + refresh after tool execution)
44
+ - Tool activity indicator before the model name on line 2: running tool, streaming (thinking), last completed tool; hidden when idle
45
+ - Colored indicator: accent while running, success when completed, dim while thinking
46
+
47
+ ### Fixed
48
+
49
+ - Thinking level stuck at `off`: now reads the latest `thinking_level_change` session entry, correctly following Shift+Tab (same data source as the built-in footer)
50
+ - MCP extension status color: preserves the original accent color with a dimmed `MCP:` label, no longer loses color on theme changes
51
+ - `/tf` command description corrected to "Toggle pi-tidy-footer on / off"; unified to standard hyphens in UI text
52
+
53
+ ## [0.1.0] — 2026-07-18
54
+
55
+ ### Added
56
+
57
+ - First release
58
+ - `/tf` command to toggle footer without cost ($)
59
+ - Persistent toggle state across sessions
60
+ - Preserves all other built-in footer info
61
+
62
+ ---
63
+
64
+ ## 中文
65
+
66
+ ## [0.4.0] - 2026-07-22
67
+
68
+ ### 新增
69
+
70
+ - `/se` 命令:查看或设置扩展状态排序,持久化存储
71
+ - 扩展状态按用户自定义顺序排列,未列出的扩展自动排至末尾
72
+
73
+ ### 变更
74
+
75
+ - Ponytail 和 Caveman 状态格式化从正则重组改为 replace 方式,保留原生颜色
76
+ - Ponytail 状态:移除 ⚡ 符号,级别字母全小写,🐴 符号保留
77
+ - Caveman 状态:标签首字母大写为 `Caveman:`,级别字母全小写
78
+
79
+ ### 修复
80
+
81
+ - `writePersistedEnabled` 不再覆盖其他状态键(余额阈值、扩展顺序)
82
+ - 删除 `dispose()` 中的重复 `clearTimeout` 调用
83
+ - 删除 `formatToolActivity` 中不可达的兜底逻辑
84
+ - 上下文用量检查 `!== null` → `!= null`(正确处置 undefined)
85
+ - DeepSeek 余额查询:`parseFloat` 前添加空值保护,避免隐式依赖异常流
86
+ - 删除 `readPersistedEnabled` 和 `readExtensionOrder` 中的冗余 `existsSync` 调用
87
+
88
+ ## [0.3.0] — 2026-07-19
89
+
90
+ ### 新增
91
+
92
+ - 第 2 行模型名后显示当前 provider 的账户余额(`¤¥` 格式,支持 DeepSeek 和 Kimi),每个 turn 结束后自动刷新(30s 冷却);余额低于警告线变黄、低于红线变红
93
+ - `/bt <warn> <alert>` 命令:自定义余额黄/红阈值,持久化存储,warn 必须大于 alert
94
+
95
+ ### 变更
96
+
97
+ - 工具活动指示精简为仅显示运行中的工具,快工具至少保留 150ms 驻留;移除 thinking 和已完成状态
98
+ - README:去除费用表述改为"从左侧统计模块移除 $0.604,让统计专注 token";新增"其他扩展微调"小节
99
+
100
+ ## [0.2.0] — 2026-07-19
101
+
102
+ ### 新增
103
+
104
+ - 第 1 行分支名旁显示 Git 状态标记:`⇡` 领先、`⇣` 落后、`+` 已暂存、`~` 已修改、`?` 未跟踪、`!` 冲突;干净仓库不显示;异步获取(250ms 防抖 + 30s 轮询 + 工具结束后刷新)
105
+ - 第 2 行模型名前显示工具活动指示:运行中工具、流式生成、刚完成的工具;空闲时不显示
106
+ - 工具指示着色:运行中 accent、刚完成 success、thinking dim
107
+
108
+ ### 修复
109
+
110
+ - thinking level 始终显示 off:改为从会话条目中获取最近一次的 `thinking_level_change`,正确跟随 Shift+Tab(数据源与内置页脚等价)
111
+ - MCP 扩展状态颜色:保留原始强调色值,`MCP:` 前缀置灰,主题切换不再丢色
112
+ - `/tf` 命令描述修正为 "Toggle pi-tidy-footer on / off";文案统一使用普通连字符
113
+
114
+ ## [0.1.0] — 2026-07-18
115
+
116
+ ### 新增
117
+
118
+ - 首个正式版本
119
+ - `/tf` 命令切换页脚,去除费用 ($) 显示
120
+ - 切换状态跨会话持久化
121
+ - 保留内置页脚全部其他信息
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eriiic7z
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,71 @@
1
+ # pi-tidy-footer
2
+
3
+ [中文](#中文)
4
+
5
+ Tidy footer for Pi coding agent — native layout, layered with new features: extension sort order, token balance display (color-coded warnings with configurable thresholds), git status tokens, and tool activity indicators; minor display tweaks for selected third-party extensions, keeping it clean.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:pi-tidy-footer
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **Account balance** — Shows DeepSeek or Kimi account balance after the model name (`¤¥` format), yellow below warn threshold, red below alert threshold, both configurable
16
+ - **Extension sort order** — Custom sort order for extension statuses, persisted across sessions; run empty command to view the current order prompt
17
+ - **Git status tokens** — Repo state next to the branch name: `⇡` ahead, `⇣` behind, `+` staged, `~` modified, `?` untracked, `!` conflicts; hidden when clean
18
+ - **Tool activity indicator** — Shows the running tool (highlighted) before the model name with brief hold for fast tools; hidden when idle
19
+ - **Hide per-session cost** — Removes `$0.604` from the left stats area, keeping it token-focused
20
+ - **Toggle & persistence** — `/tf` toggles back to the original footer anytime; all settings survive restarts
21
+ - **Mostly native** — Everything else (model, token stats, context %, git branch) kept intact
22
+
23
+ ## Display tweaks for other extensions
24
+
25
+ - **pi-mcp-adapter** — Dimmed the `MCP:` label while preserving its original accent color, keeping the status line visually coherent across theme changes
26
+ - **ponytail** — Removed ⚡ emoji from status, level text lowercased, 🐴 emoji kept
27
+ - **caveman** — Label capitalised to `Caveman:`, level text lowercased
28
+
29
+ ## Commands
30
+
31
+ | Command | Description |
32
+ | --- | --- |
33
+ | `/tf` | Toggle pi-tidy-footer (on/off) |
34
+ | `/bt <warn> <alert>` | Set balance yellow/red thresholds (warn > alert required) |
35
+ | `/se` | Show or set extension status sort order |
36
+
37
+ ---
38
+
39
+ ## 中文
40
+
41
+ 为 Pi coding agent 打造的整洁页脚 — 沿用原生布局,叠加了新的功能:插件自定义排序、token 余额显示(颜色警告、自定义警告阈值)、Git 状态标记、工具活动指示;对部分第三方插件进行名称显示微调,保持简洁。
42
+
43
+ ## 安装
44
+
45
+ ```bash
46
+ pi install npm:pi-tidy-footer
47
+ ```
48
+
49
+ ## 功能
50
+
51
+ - **账户余额** — 模型名后显示 DeepSeek / Kimi 账户余额(`¤¥` 格式),低于预警值(warn)变黄,低于警报值(alert)变红,阈值可自定义
52
+ - **插件自定义排序** — 扩展状态按自定义顺序排列,持久化存储;可使用空命令查看排序字段提示
53
+ - **Git 状态标记** — 分支名旁显示仓库状态:`⇡` 领先、`⇣` 落后、`+` 已暂存、`~` 已修改、`?` 未跟踪、`!` 冲突;干净仓库不显示
54
+ - **工具活动指示** — 模型名前实时显示正在运行的工具(高亮),快工具至少有短暂停留;空闲时不显示
55
+ - **去除会话费用显示** — 从左侧统计模块移除 `$0.604`,让统计专注 token
56
+ - **开关与持久化** — `/tf` 随时切回原始页脚;所有设置重启后保持不变
57
+ - **基本保留原生样式** — 其余所有内置页脚信息(模型、Token 统计、上下文百分比、Git 分支)保持不变
58
+
59
+ ## 其他扩展的显示微调
60
+
61
+ - **pi-mcp-adapter** — `MCP:` 标签置灰、保留原始强调色值,使状态行在不同主题下保持视觉协调
62
+ - **ponytail** — 状态中移除 ⚡ 符号,级别字母全小写,🐴 符号保留
63
+ - **caveman** — 标签首字母大写为 `Caveman:`,级别字母全小写
64
+
65
+ ## 命令
66
+
67
+ | 命令 | 说明 |
68
+ | --- | --- |
69
+ | `/tf` | 切换 pi-tidy-footer 页脚(开/关) |
70
+ | `/bt <warn> <alert>` | 设置余额黄/红阈值(warn > alert) |
71
+ | `/se` | 查看或设置扩展状态排列顺序 |
@@ -0,0 +1,735 @@
1
+ /**
2
+ * pi-tidy-footer — removes cost ($) from the built-in Pi footer.
3
+ * Toggle with /tf. State persists across restarts.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
10
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+
12
+ const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
13
+
14
+ /* ------------------------------------------------------------------ */
15
+ /* persistence */
16
+ /* ------------------------------------------------------------------ */
17
+
18
+ const STATE_DIR = join(homedir(), ".pi", "agent", "extensions");
19
+ const STATE_FILE = join(STATE_DIR, "pi-tidy-footer-state.json");
20
+
21
+ function readPersistedEnabled(): boolean {
22
+ try {
23
+ return JSON.parse(readFileSync(STATE_FILE, "utf-8")).enabled === true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function readThresholds(): { warn: number; alert: number } {
30
+ try {
31
+ const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
32
+ let w = Number(raw?.balanceThresholdWarn);
33
+ let a = Number(raw?.balanceThresholdAlert);
34
+ if (!Number.isFinite(w) || !Number.isFinite(a) || w <= a) {
35
+ w = 30;
36
+ a = 10;
37
+ }
38
+ return { warn: w, alert: a };
39
+ } catch {
40
+ return { warn: 30, alert: 10 };
41
+ }
42
+ }
43
+
44
+ function writeThresholds(warn: number, alert: number): void {
45
+ try {
46
+ const prev: any = existsSync(STATE_FILE)
47
+ ? JSON.parse(readFileSync(STATE_FILE, "utf-8"))
48
+ : {};
49
+ mkdirSync(STATE_DIR, { recursive: true });
50
+ writeFileSync(
51
+ STATE_FILE,
52
+ JSON.stringify({
53
+ ...prev,
54
+ balanceThresholdWarn: warn,
55
+ balanceThresholdAlert: alert,
56
+ }),
57
+ "utf-8",
58
+ );
59
+ } catch {
60
+ /* no-op */
61
+ }
62
+ }
63
+
64
+ function writePersistedEnabled(enabled: boolean): void {
65
+ try {
66
+ const prev: any = existsSync(STATE_FILE)
67
+ ? JSON.parse(readFileSync(STATE_FILE, "utf-8"))
68
+ : {};
69
+ mkdirSync(STATE_DIR, { recursive: true });
70
+ writeFileSync(STATE_FILE, JSON.stringify({ ...prev, enabled }), "utf-8");
71
+ } catch {
72
+ /* no-op */
73
+ }
74
+ }
75
+
76
+ function readExtensionOrder(): string[] {
77
+ const fallback = ["caveman", "ponytail", "mcp", "pi-lens-lsp"];
78
+ try {
79
+ const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
80
+ return Array.isArray(raw?.extensionOrder) ? raw.extensionOrder : fallback;
81
+ } catch {
82
+ return fallback;
83
+ }
84
+ }
85
+
86
+ function writeExtensionOrder(order: string[]): void {
87
+ try {
88
+ const prev: any = existsSync(STATE_FILE)
89
+ ? JSON.parse(readFileSync(STATE_FILE, "utf-8"))
90
+ : {};
91
+ mkdirSync(STATE_DIR, { recursive: true });
92
+ writeFileSync(
93
+ STATE_FILE,
94
+ JSON.stringify({ ...prev, extensionOrder: order }),
95
+ "utf-8",
96
+ );
97
+ } catch {
98
+ /* no-op */
99
+ }
100
+ }
101
+
102
+ function getApiKey(provider: string): string | undefined {
103
+ try {
104
+ return (JSON.parse(readFileSync(AUTH_PATH, "utf-8")) as any)[provider]?.key;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ async function fetchBalance(
111
+ provider: string,
112
+ key: string,
113
+ ): Promise<string | undefined> {
114
+ const url =
115
+ provider === "deepseek"
116
+ ? "https://api.deepseek.com/user/balance"
117
+ : provider === "moonshotai-cn"
118
+ ? "https://api.moonshot.cn/v1/users/me/balance"
119
+ : null;
120
+ if (!url) return undefined;
121
+ const resp = await fetch(url, {
122
+ headers: { Authorization: `Bearer ${key}` },
123
+ });
124
+ if (!resp.ok) return undefined;
125
+ const data = (await resp.json()) as any;
126
+ if (provider === "deepseek") {
127
+ const v = data?.balance_infos?.[0]?.total_balance;
128
+ return v != null ? parseFloat(v).toFixed(2) : undefined;
129
+ }
130
+ if (provider === "moonshotai-cn") {
131
+ const bal = data?.data;
132
+ const cash = Number.parseFloat(bal?.cash_balance ?? "");
133
+ const voucher = Number.parseFloat(bal?.voucher_balance ?? "");
134
+ const available = Number.parseFloat(bal?.available_balance ?? "");
135
+ const raw =
136
+ Number.isNaN(cash) || Number.isNaN(voucher)
137
+ ? Number.isNaN(available)
138
+ ? undefined
139
+ : available
140
+ : cash + voucher;
141
+ return raw === undefined ? undefined : raw.toFixed(2);
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ /* ------------------------------------------------------------------ */
147
+ /* helpers */
148
+ /* ------------------------------------------------------------------ */
149
+
150
+ function fmtTok(n: number): string {
151
+ if (n < 1000) return `${n}`;
152
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
153
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
154
+ return `${(n / 1_000_000).toFixed(1)}M`;
155
+ }
156
+
157
+ function fmtCwd(cwd: string, home: string | undefined): string {
158
+ if (!home) return cwd;
159
+ const rc = resolve(cwd);
160
+ const rh = resolve(home);
161
+ const rel = relative(rh, rc);
162
+ if (
163
+ rel === "" ||
164
+ (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
165
+ )
166
+ return rel === "" ? "~" : `~${sep}${rel}`;
167
+ return cwd;
168
+ }
169
+
170
+ /* ------------------------------------------------------------------ */
171
+ /* git status tokens (feature ported from pi-statusline, plain style) */
172
+ /* ------------------------------------------------------------------ */
173
+
174
+ function parseGitStatusTokens(stdout: string): string {
175
+ let ahead = 0,
176
+ behind = 0,
177
+ staged = 0,
178
+ modified = 0,
179
+ untracked = 0,
180
+ conflicts = 0;
181
+ for (const line of stdout.split("\n")) {
182
+ if (!line) continue;
183
+ if (line.startsWith("## ")) {
184
+ const a = line.match(/\bahead (\d+)/);
185
+ const b = line.match(/\bbehind (\d+)/);
186
+ ahead = a ? Number(a[1]) : 0;
187
+ behind = b ? Number(b[1]) : 0;
188
+ continue;
189
+ }
190
+ const x = line[0] ?? " ";
191
+ const y = line[1] ?? " ";
192
+ if (x === "?" && y === "?") {
193
+ untracked++;
194
+ continue;
195
+ }
196
+ if (
197
+ (x === "D" && y === "D") ||
198
+ (x === "A" && y === "A") ||
199
+ x === "U" ||
200
+ y === "U"
201
+ ) {
202
+ conflicts++;
203
+ continue;
204
+ }
205
+ if (x !== " " && x !== "?" && x !== "!") staged++;
206
+ if (y !== " " && y !== "?" && y !== "!") modified++;
207
+ }
208
+ const parts: string[] = [];
209
+ if (ahead) parts.push(`⇡${ahead}`);
210
+ if (behind) parts.push(`⇣${behind}`);
211
+ if (staged) parts.push(`+${staged}`);
212
+ if (modified) parts.push(`~${modified}`);
213
+ if (untracked) parts.push(`?${untracked}`);
214
+ if (conflicts) parts.push(`!${conflicts}`);
215
+ return parts.join(" ");
216
+ }
217
+
218
+ /* ------------------------------------------------------------------ */
219
+ /* tool activity */
220
+ /* ------------------------------------------------------------------ */
221
+
222
+ interface ToolActivityState {
223
+ active: Map<string, number>;
224
+ minDisplayQueue: string[];
225
+ minDisplayTimer: ReturnType<typeof setTimeout> | undefined;
226
+ }
227
+
228
+ interface LiveHooks {
229
+ requestRender: (() => void) | undefined;
230
+ refreshGit: (() => void) | undefined;
231
+ refreshBalance: (() => void) | undefined;
232
+ }
233
+
234
+ function formatToolActivity(state: ToolActivityState): string {
235
+ const active = [...state.active.entries()];
236
+ if (active.length > 0) {
237
+ const [name, count] = active[0];
238
+ const suffix =
239
+ count > 1
240
+ ? `×${count}`
241
+ : active.length > 1
242
+ ? `+${active.length - 1}`
243
+ : "";
244
+ return `⚙ ${name}${suffix}`;
245
+ }
246
+ return "";
247
+ }
248
+
249
+ /* ------------------------------------------------------------------ */
250
+ /* footer factory */
251
+ /* ------------------------------------------------------------------ */
252
+
253
+ function makeFooter(
254
+ ctx: any,
255
+ tui: any,
256
+ theme: any,
257
+ fd: any,
258
+ toolState: ToolActivityState,
259
+ live: LiveHooks,
260
+ thresholds: { warn: number; alert: number },
261
+ extensionOrder: string[],
262
+ ) {
263
+ let disposed = false;
264
+ let gitTokens = "";
265
+ let gitInFlight = false;
266
+ let gitQueued = false;
267
+ let gitDebounce: ReturnType<typeof setTimeout> | undefined;
268
+ let balanceText = "";
269
+ let balanceProvider = "";
270
+ let balanceStale = false;
271
+ let balanceLastFetch = 0;
272
+ let balanceInFlight = false;
273
+
274
+ const runGitStatus = () => {
275
+ if (disposed) return;
276
+ if (gitInFlight) {
277
+ gitQueued = true;
278
+ return;
279
+ }
280
+ gitInFlight = true;
281
+ void (async () => {
282
+ try {
283
+ const result = await ctx.exec(
284
+ "git",
285
+ [
286
+ "--no-optional-locks",
287
+ "status",
288
+ "--porcelain=v1",
289
+ "--branch",
290
+ "--untracked-files=normal",
291
+ ],
292
+ { cwd: ctx.sessionManager.getCwd(), timeout: 3000 },
293
+ );
294
+ gitTokens =
295
+ result.code === 0 && !result.killed
296
+ ? parseGitStatusTokens(result.stdout)
297
+ : "";
298
+ } catch {
299
+ gitTokens = "";
300
+ } finally {
301
+ gitInFlight = false;
302
+ if (!disposed) tui.requestRender();
303
+ if (gitQueued) {
304
+ gitQueued = false;
305
+ runGitStatus();
306
+ }
307
+ }
308
+ })();
309
+ };
310
+
311
+ const refreshGit = () => {
312
+ if (disposed) return;
313
+ if (gitDebounce) clearTimeout(gitDebounce);
314
+ gitDebounce = setTimeout(() => {
315
+ gitDebounce = undefined;
316
+ runGitStatus();
317
+ }, 250);
318
+ };
319
+
320
+ const unsub = fd.onBranchChange(() => {
321
+ gitTokens = "";
322
+ refreshGit();
323
+ tui.requestRender();
324
+ });
325
+ const gitInterval = setInterval(runGitStatus, 30000);
326
+ live.requestRender = () => tui.requestRender();
327
+ live.refreshGit = refreshGit;
328
+ runGitStatus();
329
+
330
+ const runBalance = () => {
331
+ if (disposed) return;
332
+ const provider = ctx.model?.provider;
333
+ if (provider !== "deepseek" && provider !== "moonshotai-cn") {
334
+ balanceText = "";
335
+ balanceProvider = "";
336
+ balanceStale = false;
337
+ tui.requestRender();
338
+ return;
339
+ }
340
+ const now = Date.now();
341
+ if (
342
+ balanceLastFetch > 0 &&
343
+ now - balanceLastFetch < 30_000 &&
344
+ balanceProvider === provider
345
+ ) {
346
+ // still fresh
347
+ return;
348
+ }
349
+ balanceProvider = provider;
350
+ balanceLastFetch = now;
351
+ if (balanceInFlight) return;
352
+ const key = getApiKey(provider);
353
+ if (!key) return;
354
+ balanceInFlight = true;
355
+ void (async () => {
356
+ try {
357
+ const value = await fetchBalance(provider, key);
358
+ if (disposed || ctx.model?.provider !== provider) {
359
+ balanceText = "";
360
+ balanceProvider = "";
361
+ } else if (value !== undefined) {
362
+ balanceText = `¤¥${value}`;
363
+ balanceStale = false;
364
+ } else {
365
+ balanceText = "";
366
+ balanceProvider = "";
367
+ }
368
+ } catch {
369
+ if (!disposed && ctx.model?.provider === provider) {
370
+ if (balanceText) balanceStale = true;
371
+ else {
372
+ balanceText = "";
373
+ balanceProvider = "";
374
+ }
375
+ }
376
+ } finally {
377
+ balanceInFlight = false;
378
+ if (!disposed) tui.requestRender();
379
+ }
380
+ })();
381
+ };
382
+ live.refreshBalance = runBalance;
383
+ if (!balanceText) runBalance();
384
+
385
+ return {
386
+ dispose() {
387
+ disposed = true;
388
+ unsub();
389
+ clearInterval(gitInterval);
390
+ if (gitDebounce) clearTimeout(gitDebounce);
391
+ live.requestRender = undefined;
392
+ live.refreshGit = undefined;
393
+ live.refreshBalance = undefined;
394
+ },
395
+ render(width: number): string[] {
396
+ const session = ctx.sessionManager;
397
+ /* token stats — cost intentionally omitted */
398
+ let inp = 0,
399
+ out = 0,
400
+ cr = 0,
401
+ cw = 0;
402
+ let hitRate: number | undefined;
403
+ for (const e of session.getBranch()) {
404
+ if (e.type === "message" && e.message.role === "assistant") {
405
+ const msg = e.message as AssistantMessage;
406
+ inp += msg.usage.input;
407
+ out += msg.usage.output;
408
+ cr += msg.usage.cacheRead;
409
+ cw += msg.usage.cacheWrite;
410
+ const prompt =
411
+ msg.usage.input + msg.usage.cacheRead + msg.usage.cacheWrite;
412
+ hitRate =
413
+ prompt > 0 ? (msg.usage.cacheRead / prompt) * 100 : undefined;
414
+ }
415
+ }
416
+
417
+ /* context usage */
418
+ const cu = ctx.getContextUsage();
419
+ const ctxWin = cu?.contextWindow ?? ctx.model?.contextWindow ?? 0;
420
+ const pct = cu?.percent ?? 0;
421
+ const pctStr = cu?.percent != null ? pct.toFixed(1) : "?";
422
+
423
+ /* pwd / git */
424
+ let pwd = fmtCwd(
425
+ session.getCwd(),
426
+ process.env.HOME || process.env.USERPROFILE,
427
+ );
428
+ const gitBranch = fd.getGitBranch();
429
+ if (gitBranch)
430
+ pwd = gitTokens
431
+ ? `${pwd} (${gitBranch} ${gitTokens})`
432
+ : `${pwd} (${gitBranch})`;
433
+ const sname = session.getSessionName();
434
+ if (sname) pwd = `${pwd} • ${sname}`;
435
+
436
+ /* stats line */
437
+ const parts: string[] = [];
438
+ if (inp) parts.push(`↑${fmtTok(inp)}`);
439
+ if (out) parts.push(`↓${fmtTok(out)}`);
440
+ if (cr) parts.push(`R${fmtTok(cr)}`);
441
+ if (cw) parts.push(`W${fmtTok(cw)}`);
442
+ if ((cr || cw) && hitRate !== undefined)
443
+ parts.push(`CH${hitRate.toFixed(1)}%`);
444
+ const ctxPctDisp =
445
+ pctStr === "?" ? `?/${fmtTok(ctxWin)}` : `${pctStr}%/${fmtTok(ctxWin)}`;
446
+ let ctxPctStr: string;
447
+ if (pct > 90) ctxPctStr = theme.fg("error", ctxPctDisp);
448
+ else if (pct > 70) ctxPctStr = theme.fg("warning", ctxPctDisp);
449
+ else ctxPctStr = ctxPctDisp;
450
+ parts.push(ctxPctStr);
451
+
452
+ let left = parts.join(" ");
453
+ let lw = visibleWidth(left);
454
+ if (lw > width) left = truncateToWidth(left, width, "...");
455
+ lw = visibleWidth(left);
456
+
457
+ /* model / thinking — get thinking level from session entries (mirrors native footer) */
458
+ const mName = ctx.model?.id || "no-model";
459
+ let right = mName;
460
+ if (ctx.model?.reasoning) {
461
+ let tl = "off";
462
+ // Walk session branch backwards for most recent thinking_level_change
463
+ const branch = session.getBranch();
464
+ for (let i = branch.length - 1; i >= 0; i--) {
465
+ const e = branch[i];
466
+ if (e.type === "thinking_level_change") {
467
+ tl = (e as any).thinkingLevel || "off";
468
+ break;
469
+ }
470
+ }
471
+ right = tl === "off" ? `${mName} • thinking off` : `${mName} • ${tl}`;
472
+ }
473
+ const toolText = formatToolActivity(toolState);
474
+ const toolColor = "accent";
475
+ const toolSeg = toolText ? `${theme.fg(toolColor, toolText)} ` : "";
476
+ const balanceTextVal = balanceText;
477
+ const balanceProviderVal = ctx.model?.provider;
478
+ let balanceSeg = "";
479
+ if (balanceTextVal && balanceProviderVal === balanceProvider) {
480
+ const num = Number.parseFloat(balanceTextVal.slice(2));
481
+ const color = Number.isNaN(num)
482
+ ? "dim"
483
+ : num < thresholds.alert
484
+ ? "error"
485
+ : num < thresholds.warn
486
+ ? "warning"
487
+ : "dim";
488
+ balanceSeg = ` ${theme.fg(color, balanceTextVal + (balanceStale ? "?" : ""))}`;
489
+ }
490
+ const rw =
491
+ visibleWidth(right) +
492
+ (toolText ? visibleWidth(toolText) + 2 : 0) +
493
+ visibleWidth(balanceSeg);
494
+
495
+ const pad = width - lw - rw;
496
+ let line2: string;
497
+ if (pad >= 0) {
498
+ line2 =
499
+ theme.fg("dim", left) +
500
+ " ".repeat(pad) +
501
+ toolSeg +
502
+ theme.fg("dim", right) +
503
+ balanceSeg;
504
+ } else if (width - lw - 2 > 0) {
505
+ line2 =
506
+ theme.fg("dim", left) +
507
+ theme.fg(
508
+ "dim",
509
+ " " +
510
+ truncateToWidth(
511
+ (toolText ? `${toolText} ` : "") + right + balanceSeg,
512
+ width - lw - 2,
513
+ "",
514
+ ),
515
+ );
516
+ } else {
517
+ line2 = theme.fg("dim", left);
518
+ }
519
+
520
+ /* extension statuses */
521
+ const extItems: string[] = [];
522
+ const raw = fd.getExtensionStatuses();
523
+ let mcpRgb = ""; // capture original MCP accent colour so theme changes work
524
+ if (raw.size > 0) {
525
+ const order = new Map<string, number>();
526
+ extensionOrder.forEach((key, i) => order.set(key, i));
527
+ const sorted = (Array.from(raw.entries()) as [string, string][])
528
+ .sort(([a], [b]) => {
529
+ const oa = order.get(a) ?? 99;
530
+ const ob = order.get(b) ?? 99;
531
+ return oa !== ob ? oa - ob : a.localeCompare(b);
532
+ })
533
+ .map(([k, v]) => {
534
+ if (k === "caveman") {
535
+ const s = (v as string)
536
+ .replace("caveman level:", "Caveman:")
537
+ .replace(/(FULL|ULTRA|LITE|OFF)/g, (w) => w.toLowerCase());
538
+ return s;
539
+ }
540
+ if (k === "ponytail") {
541
+ const s = (v as string)
542
+ .replace("⚡ ", "")
543
+ .replace(/(FULL|ULTRA|LITE|OFF)/g, (w) => w.toLowerCase())
544
+ .replace(
545
+ " 🐴 \x1b[38;2;128;128;128mponytail:",
546
+ " 🐴\x1b[38;2;128;128;128mponytail:",
547
+ );
548
+ return s;
549
+ }
550
+ return (v as string).trim();
551
+ })
552
+ .filter(Boolean);
553
+ extItems.push(...sorted);
554
+ }
555
+
556
+ // Extract original MCP accent RGB from the raw text before we strip colours
557
+ const rawMcp = raw.get("mcp");
558
+ if (rawMcp) {
559
+ const m = (rawMcp as string).match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
560
+ if (m) mcpRgb = `\x1b[38;2;${m[1]};${m[2]};${m[3]}m`;
561
+ }
562
+
563
+ const lines: string[] = [
564
+ truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")),
565
+ line2,
566
+ ];
567
+ if (extItems.length > 0) {
568
+ let statusLine = extItems.join(" ");
569
+ // Rewrite MCP: prefix with dim colour, reset after the colon
570
+ statusLine = statusLine.replace(
571
+ /MCP:/,
572
+ `\x1b[38;2;128;128;128mMCP:${mcpRgb || "\x1b[38;2;138;190;183m"}`,
573
+ );
574
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
575
+ }
576
+
577
+ return lines;
578
+ },
579
+ };
580
+ }
581
+
582
+ /* ------------------------------------------------------------------ */
583
+ /* extension entry-point */
584
+ /* ------------------------------------------------------------------ */
585
+
586
+ export default function (pi: any) {
587
+ const toolState: ToolActivityState = {
588
+ active: new Map(),
589
+ minDisplayQueue: [],
590
+ minDisplayTimer: undefined,
591
+ };
592
+ const live: LiveHooks = {
593
+ requestRender: undefined,
594
+ refreshGit: undefined,
595
+ refreshBalance: undefined,
596
+ };
597
+
598
+ function applyFooter(ctx: any) {
599
+ const thresholds = readThresholds();
600
+ const extensionOrder = readExtensionOrder();
601
+ ctx.ui.setFooter((tui: any, theme: any, fd: any) =>
602
+ makeFooter(
603
+ ctx,
604
+ tui,
605
+ theme,
606
+ fd,
607
+ toolState,
608
+ live,
609
+ thresholds,
610
+ extensionOrder,
611
+ ),
612
+ );
613
+ }
614
+
615
+ let enabled = readPersistedEnabled();
616
+
617
+ pi.on("session_start", async (_event: any, ctx: any) => {
618
+ enabled = readPersistedEnabled();
619
+ toolState.active.clear();
620
+ toolState.minDisplayQueue.length = 0;
621
+ if (toolState.minDisplayTimer) clearTimeout(toolState.minDisplayTimer);
622
+ toolState.minDisplayTimer = undefined;
623
+ if (enabled) applyFooter(ctx);
624
+ });
625
+
626
+ pi.on("tool_execution_start", (event: any) => {
627
+ toolState.active.set(
628
+ event.toolName,
629
+ (toolState.active.get(event.toolName) ?? 0) + 1,
630
+ );
631
+ live.requestRender?.();
632
+ });
633
+
634
+ pi.on("tool_execution_end", (event: any) => {
635
+ const n = toolState.active.get(event.toolName) ?? 0;
636
+ if (n <= 1) toolState.active.delete(event.toolName);
637
+ else toolState.active.set(event.toolName, n - 1);
638
+ // minimum 150ms display so fast tools don't flicker
639
+ if (!toolState.active.has(event.toolName)) {
640
+ toolState.minDisplayQueue.push(event.toolName);
641
+ if (!toolState.minDisplayTimer) {
642
+ toolState.minDisplayTimer = setTimeout(() => {
643
+ toolState.minDisplayQueue = [];
644
+ toolState.minDisplayTimer = undefined;
645
+ live.requestRender?.();
646
+ }, 150);
647
+ }
648
+ } else {
649
+ toolState.minDisplayQueue = toolState.minDisplayQueue.filter(
650
+ (q) => q !== event.toolName,
651
+ );
652
+ }
653
+ live.requestRender?.();
654
+ live.refreshGit?.();
655
+ });
656
+
657
+ pi.on("agent_start", () => {
658
+ live.requestRender?.();
659
+ });
660
+
661
+ pi.on("agent_end", () => {
662
+ live.requestRender?.();
663
+ live.refreshGit?.();
664
+ });
665
+
666
+ pi.on("model_select", () => {
667
+ live.refreshBalance?.();
668
+ });
669
+
670
+ pi.on("turn_end", () => {
671
+ live.refreshBalance?.();
672
+ });
673
+
674
+ pi.registerCommand("bt", {
675
+ description:
676
+ "Set balance thresholds: warn (yellow) / alert (red), warn > alert",
677
+ handler: async (args: string, ctx: any) => {
678
+ const parts = args.trim().split(/\s+/);
679
+ const warn = Number(parts[0]);
680
+ const alert = Number(parts[1]);
681
+ if (
682
+ parts.length !== 2 ||
683
+ !Number.isFinite(warn) ||
684
+ !Number.isFinite(alert) ||
685
+ warn <= alert
686
+ ) {
687
+ ctx.ui.notify(
688
+ "Usage: /bt <warn> <alert> (warn > alert). Default: 30 10",
689
+ "error",
690
+ );
691
+ return;
692
+ }
693
+ writeThresholds(warn, alert);
694
+ // re-apply footer with fresh thresholds
695
+ if (enabled) applyFooter(ctx);
696
+ ctx.ui.notify(
697
+ `Balance thresholds: yellow < ${warn}, red < ${alert}`,
698
+ "info",
699
+ );
700
+ },
701
+ });
702
+
703
+ pi.registerCommand("tf", {
704
+ description: "Toggle pi-tidy-footer on / off",
705
+ handler: async (_args: string, ctx: any) => {
706
+ enabled = !enabled;
707
+ writePersistedEnabled(enabled);
708
+
709
+ if (enabled) {
710
+ applyFooter(ctx);
711
+ ctx.ui.notify("Cost-free footer ON", "info");
712
+ } else {
713
+ ctx.ui.setFooter(undefined);
714
+ ctx.ui.notify("Default footer restored", "info");
715
+ }
716
+ },
717
+ });
718
+
719
+ pi.registerCommand("se", {
720
+ description:
721
+ "Sort extension statuses (no args = show order, keys = set order)",
722
+ handler: async (args: string, ctx: any) => {
723
+ const trimmed = args.trim();
724
+ if (!trimmed) {
725
+ const current = readExtensionOrder();
726
+ ctx.ui.notify(`Extension order: ${current.join(" ")}`, "info");
727
+ return;
728
+ }
729
+ const keys = trimmed.split(/\s+/);
730
+ writeExtensionOrder(keys);
731
+ if (enabled) applyFooter(ctx);
732
+ ctx.ui.notify(`Extension order: ${keys.join(" ")}`, "info");
733
+ },
734
+ });
735
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "pi-tidy-footer",
3
+ "version": "0.4.0",
4
+ "description": "Tidy footer for Pi coding agent — native layout, layered with new features: extension sort order, token balance display (color-coded warnings with configurable thresholds), git status tokens, and tool activity indicators; minor display tweaks for selected third-party extensions, keeping it clean.",
5
+ "type": "module",
6
+ "main": "extensions/pi-tidy-footer.ts",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/eriiic7z/pi-tidy-footer.git"
11
+ },
12
+ "files": [
13
+ "extensions",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "pi": {
19
+ "extensions": [
20
+ "./extensions/pi-tidy-footer.ts"
21
+ ]
22
+ },
23
+ "keywords": [
24
+ "pi-package",
25
+ "pi-extension",
26
+ "extension"
27
+ ],
28
+ "author": "eriiic7z",
29
+ "devDependencies": {
30
+ "@types/node": "^26.1.1"
31
+ }
32
+ }