pi-signal-footer 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-signal-footer contributors
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,84 @@
1
+ # pi-signal-footer
2
+
3
+ English | [简体中文](README.zh-CN.md)
4
+
5
+ ```text
6
+ agent-demo · fix-context-bar │ opencode-go › ◎ deepseek-v4-flash-0731 │ ✦ max │ ⎇ main ⎔ 12% [━━──────────────────] 36k/300k
7
+ ↓ 213 ↑ 32k │ ↻ 5.1M (97%) ✎ 137k │ $0.087 │ ◷ 2h25m · 1轮 · 45 tok/s ⇄ MCP 1/1 · LSP typescript
8
+ ```
9
+
10
+ A status footer for [Pi Coding Agent](https://github.com/earendil-works/pi-mono), replacing the built-in one.
11
+
12
+ The first line shows the project path, session name, provider and model, thinking level, and git branch. Model icons are matched by model family, and paths under the home directory are abbreviated to `~`. The context bar takes whatever width is left and shows the percentage plus used and window tokens: warning color at 50%, error color at 75%, `?` while usage is unknown.
13
+
14
+ The second line shows input and output tokens, cache read with hit ratio, cache write, accumulated cost, session time and turns, and the streaming rate of the last response. MCP and LSP statuses written by other extensions are rendered in the same style; unrecognized text passes through unchanged.
15
+
16
+ On narrower terminals the two lines split into three and then six, and fields that no longer fit are truncated. All colors come from the active Pi theme. Icons are plain-text glyphs one column wide, without emoji variants.
17
+
18
+ ## Install
19
+
20
+ ### npm
21
+
22
+ ```sh
23
+ pi install npm:pi-signal-footer
24
+ ```
25
+
26
+ Tracks the latest release and updates with `pi update --extensions`. To pin a version, use `pi install npm:pi-signal-footer@0.2.1`.
27
+
28
+ ### Git
29
+
30
+ ```sh
31
+ pi install git:github.com/chengzhi-c/pi-signal-footer@v0.2.1
32
+ ```
33
+
34
+ Stays on the tag; to upgrade, rerun the command with the new tag.
35
+
36
+ ### Local path
37
+
38
+ ```sh
39
+ pi install ./pi-signal-footer
40
+ ```
41
+
42
+ Loads a directory from disk, for development and testing.
43
+
44
+ Requires Pi Coding Agent ≥ 0.84 and Node.js ≥ 22.19.0.
45
+
46
+ ## Configure
47
+
48
+ Field visibility is a single `CONFIG` object at the top of `index.ts`:
49
+
50
+ ```ts
51
+ export const CONFIG = {
52
+ showProject: true, // full project path (parent dirs dimmed, home abbreviated to ~)
53
+ showSessionName: true, // session name, when set
54
+ showDuration: true, // active time span
55
+ showTurns: true, // assistant turns
56
+ showSpeed: true, // streaming tok/s of the last response
57
+ showBranch: true, // git branch
58
+ showCacheRatio: true, // cache hit ratio
59
+ };
60
+ ```
61
+
62
+ ## Commands
63
+
64
+ ```text
65
+ /signal-footer legend show the metric legend above the editor
66
+ /signal-footer hide hide the legend
67
+ /signal-footer off switch back to the native footer (this session)
68
+ /signal-footer on re-enable this footer (this session)
69
+ ```
70
+
71
+ ## Development
72
+
73
+ ```sh
74
+ npm test
75
+ npm run typecheck
76
+ npm run check
77
+ npm run pack:check
78
+ ```
79
+
80
+ `npm run check` runs the tests and the TypeScript check; `npm run pack:check` previews the files that ship in the package. The TypeScript check skips declarations inside the Pi SDK dependency tree and checks the extension source itself strictly.
81
+
82
+ ## License
83
+
84
+ [MIT](LICENSE)
@@ -0,0 +1,84 @@
1
+ # pi-signal-footer
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ ```text
6
+ agent-demo · fix-context-bar │ opencode-go › ◎ deepseek-v4-flash-0731 │ ✦ max │ ⎇ main ⎔ 12% [━━──────────────────] 36k/300k
7
+ ↓ 213 ↑ 32k │ ↻ 5.1M (97%) ✎ 137k │ $0.087 │ ◷ 2h25m · 1轮 · 45 tok/s ⇄ MCP 1/1 · LSP typescript
8
+ ```
9
+
10
+ 为 [Pi Coding Agent](https://github.com/earendil-works/pi-mono) 提供的状态栏,替代内置 footer。
11
+
12
+ 第一行显示项目路径、会话名、provider 和模型、思考等级、Git 分支。模型图标按模型家族匹配,主目录下的路径缩写为 `~`。上下文占用条用掉行内剩余宽度,显示百分比与已用/窗口 token:占用 ≥50% 变警告色,≥75% 变错误色,未知时显示 `?`。
13
+
14
+ 第二行显示输入/输出 token、缓存读与命中率、缓存写、累计成本、会话时长与轮次,以及最近一次响应的流式速率。其他扩展写入的 MCP、LSP 状态按同一套样式渲染,识别不了的文案原样显示。
15
+
16
+ 终端变窄时两行先拆成三行、再拆成六行,放不下的字段截断显示。颜色全部取自当前 Pi 主题,图标是实测宽度为 1 的纯文本字形。
17
+
18
+ ## 安装
19
+
20
+ ### npm
21
+
22
+ ```sh
23
+ pi install npm:pi-signal-footer
24
+ ```
25
+
26
+ 不带版本号,随 `pi update --extensions` 自动更新。固定版本用 `pi install npm:pi-signal-footer@0.2.1`。
27
+
28
+ ### Git
29
+
30
+ ```sh
31
+ pi install git:github.com/chengzhi-c/pi-signal-footer@v0.2.1
32
+ ```
33
+
34
+ 固定在 tag 上;升级时用新 tag 重新执行这条命令。
35
+
36
+ ### 本地路径
37
+
38
+ ```sh
39
+ pi install ./pi-signal-footer
40
+ ```
41
+
42
+ 加载磁盘上的目录,适合开发调试。
43
+
44
+ 需要 Pi Coding Agent ≥ 0.84 和 Node.js ≥ 22.19.0。
45
+
46
+ ## 配置
47
+
48
+ 字段显隐由 `index.ts` 顶部的 `CONFIG` 对象控制:
49
+
50
+ ```ts
51
+ export const CONFIG = {
52
+ showProject: true, // 完整项目路径(上级目录弱化,主目录缩写为 ~)
53
+ showSessionName: true, // 会话名(设置后显示)
54
+ showDuration: true, // 活跃时间跨度
55
+ showTurns: true, // 交互轮次
56
+ showSpeed: true, // 最近一次响应的流式 tok/s
57
+ showBranch: true, // Git 分支
58
+ showCacheRatio: true, // 缓存命中率
59
+ };
60
+ ```
61
+
62
+ ## 命令
63
+
64
+ ```text
65
+ /signal-footer legend 在编辑器上方显示指标图例
66
+ /signal-footer hide 隐藏图例
67
+ /signal-footer off 临时切回原生状态栏(本会话)
68
+ /signal-footer on 重新启用本状态栏(本会话)
69
+ ```
70
+
71
+ ## 开发
72
+
73
+ ```sh
74
+ npm test
75
+ npm run typecheck
76
+ npm run check
77
+ npm run pack:check
78
+ ```
79
+
80
+ `npm run check` 跑测试套件和 TypeScript 检查,`npm run pack:check` 预览将随包发布的文件。TypeScript 检查跳过 Pi SDK 依赖树内的声明,扩展源码本身按严格模式检查。
81
+
82
+ ## 许可证
83
+
84
+ [MIT](LICENSE)
package/format.ts ADDED
@@ -0,0 +1,166 @@
1
+ export const LEGEND_LINES = [
2
+ "指标与符号图例",
3
+ "↓ 输入: 本会话送往模型的请求 token;↑ 输出: 模型生成的 token。",
4
+ "↻ 缓存读: 已复用的提示词缓存 token 及命中率;✎ 缓存写: 新写入缓存的 token。",
5
+ "费用($): 会话累计估算成本;⎔: 提示词上下文占用比例与窗口 token(已用/上限)。",
6
+ "模型: 图标 provider › model(图标按模型家族匹配);✦ 推理: 思考等级;⎇ 分支: 当前 Git 分支。",
7
+ "⇄ MCP: 已连接/启用的 MCP 服务器数(未满黄色、全未连灰色:懒连接的服务器首次使用前不在线属正常);LSP: 活动中的语言服务器,✗ 标记失败,无活动不显示。",
8
+ "项目: 当前完整路径(上级目录弱化、主目录缩写为 ~;含会话名,若已设置);◷: 会话活跃跨度、交互轮次与最近一次响应的流式速率(tok/s)。",
9
+ "关闭图例: /signal-footer hide",
10
+ ];
11
+
12
+ export type ProjectPathParts = { parent: string; name: string };
13
+ export type ContextBarParts = { fill: string; track: string; unknown: boolean };
14
+ export type LspChip = { failed: boolean; names: string };
15
+ export type McpStatus = { connected: number; enabled: number };
16
+
17
+ /**
18
+ * 图标按「provider/model」子串匹配模型家族。子串匹配是有意的(gpt4、chatgpt-*、
19
+ * o1-pro 等真实变体依赖它);OpenAI 宽匹配组放在所有命名家族之后,避免他牌
20
+ * -o1/-o3 后缀模型误挂 OpenAI 图标。
21
+ */
22
+ export function getModelIcon(modelId = "", provider = ""): string {
23
+ const combined = `${provider}/${modelId}`.toLowerCase();
24
+ if (combined.includes("grok") || combined.includes("xai")) return "𝕏";
25
+ if (combined.includes("glm") || combined.includes("zhipu") || combined.includes("chatglm")) return "𝐙";
26
+ if (combined.includes("claude") || combined.includes("anthropic")) return "✻";
27
+ if (combined.includes("gemini") || combined.includes("gemma") || combined.includes("google")) return "✧";
28
+ if (combined.includes("deepseek") || combined.includes("deep-seek")) return "◎";
29
+ if (combined.includes("qwen") || combined.includes("qwq") || combined.includes("tongyi")) return "𝐐";
30
+ if (combined.includes("llama") || combined.includes("meta")) return "𝕃";
31
+ if (combined.includes("mistral") || combined.includes("codestral") || combined.includes("mixtral")) return "𝐌";
32
+ if (combined.includes("kimi") || combined.includes("moonshot")) return "𝐊";
33
+ if (combined.includes("doubao") || combined.includes("bytedance")) return "𝐃";
34
+ if (combined.includes("yi-") || combined.includes("01-ai") || combined.includes("lingyi")) return "①";
35
+ if (combined.includes("minimax") || combined.includes("abab")) return "⬡";
36
+ if (combined.includes("gpt") || combined.includes("o1") || combined.includes("o3") || combined.includes("openai") || combined.includes("chatgpt")) return "⬢";
37
+ if (combined.includes("ollama") || combined.includes("local")) return "⌂";
38
+ return "◈";
39
+ }
40
+
41
+ /** token 数压缩为稳定后缀:999 → "999",1250 → "1.3k",32_000 → "32k",5_100_000 → "5.1M"。 */
42
+ export function formatTokens(count: number): string {
43
+ const value = Number.isFinite(count) && count > 0 ? count : 0;
44
+
45
+ if (value < 1_000) return Math.round(value).toString();
46
+ if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`;
47
+ if (value < 1_000_000) return `${Math.round(value / 1_000)}k`;
48
+ if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
49
+ return `${Math.round(value / 1_000_000)}M`;
50
+ }
51
+
52
+ export function formatCost(cost: number): string {
53
+ const value = Number.isFinite(cost) && cost > 0 ? cost : 0;
54
+ return `$${value.toFixed(3)}`;
55
+ }
56
+
57
+ export function formatCacheHitRatio(read: number, write: number): string {
58
+ const r = Number.isFinite(read) && read > 0 ? read : 0;
59
+ const w = Number.isFinite(write) && write > 0 ? write : 0;
60
+ const total = r + w;
61
+ if (total === 0) return "0%";
62
+ return `${Math.round((r / total) * 100)}%`;
63
+ }
64
+
65
+ export function formatDuration(ms: number): string {
66
+ if (!Number.isFinite(ms) || ms <= 0) return "0m";
67
+ const minutes = Math.floor(ms / 60_000);
68
+ if (minutes < 60) return `${minutes}m`;
69
+ const hours = Math.floor(minutes / 60);
70
+ return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
71
+ }
72
+
73
+ /** 最近一次响应的生成速率:输出 token ÷ 首 token 到响应结束的墙钟时间。 */
74
+ export function formatSpeed(tokens: number, ms: number): string {
75
+ if (!Number.isFinite(tokens) || !Number.isFinite(ms) || tokens <= 0 || ms <= 0) return "";
76
+ return `${Math.round(tokens / (ms / 1000))} tok/s`;
77
+ }
78
+
79
+ /** 项目槽位:完整路径,主目录缩写为 ~。返回弱化的上级目录与加粗的末级目录名。 */
80
+ export function splitProjectPath(cwd = "", home = ""): ProjectPathParts {
81
+ const normalize = (p: unknown): string => String(p ?? "").replace(/[\\/]+$/, "");
82
+ const c = normalize(cwd);
83
+ const h = normalize(home);
84
+ if (!c) return { parent: "", name: "" };
85
+
86
+ let display = c;
87
+ const windowsPath = /^[A-Za-z]:[\\/]/.test(c) || /^[A-Za-z]:[\\/]/.test(h);
88
+ const comparableCwd = windowsPath ? c.toLowerCase() : c;
89
+ const comparableHome = windowsPath ? h.toLowerCase() : h;
90
+ if (h && comparableCwd === comparableHome) return { parent: "", name: "~" };
91
+ if (h && comparableCwd.startsWith(comparableHome) && (c[h.length] === "\\" || c[h.length] === "/")) {
92
+ display = `~${c.slice(h.length)}`;
93
+ }
94
+
95
+ const parts = display.split(/[\\/]/).filter(Boolean);
96
+ const name = parts[parts.length - 1] ?? "";
97
+ const root = /^[\\/]/.test(display) ? "/" : "";
98
+ const parent = parts.slice(0, -1).join("/");
99
+ return { parent: `${root}${parent ? `${parent}/` : ""}`, name };
100
+ }
101
+
102
+ const CONTEXT_BAR_FILLED = "━";
103
+ const CONTEXT_BAR_EMPTY = "─";
104
+
105
+ export function normalizeContextPercent(percent: unknown): number | undefined {
106
+ if (typeof percent !== "number" || !Number.isFinite(percent)) return undefined;
107
+ return Math.min(100, Math.max(0, percent));
108
+ }
109
+
110
+ export function formatContext(tokens: number | null | undefined, contextWindow: number | null | undefined): string {
111
+ const used = typeof tokens === "number" && Number.isFinite(tokens) && tokens >= 0 ? Math.round(tokens) : undefined;
112
+ const window = typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
113
+ ? contextWindow
114
+ : undefined;
115
+
116
+ return `${used === undefined ? "?" : formatTokens(used)}/${window === undefined ? "?" : formatTokens(window)}`;
117
+ }
118
+
119
+ /** 唯一的填充数学:返回已填充/剩余轨道字符串,供着色组装与纯文本输出共用。 */
120
+ export function contextBarParts(percent: unknown, width: number): ContextBarParts {
121
+ const length = Number.isInteger(width) && width > 0 ? width : 1;
122
+ const normalizedPercent = normalizeContextPercent(percent);
123
+
124
+ if (normalizedPercent === undefined) return { fill: "", track: "?".repeat(length), unknown: true };
125
+
126
+ const filled = normalizedPercent === 0
127
+ ? 0
128
+ : Math.max(1, Math.round((normalizedPercent / 100) * length));
129
+ return { fill: CONTEXT_BAR_FILLED.repeat(filled), track: CONTEXT_BAR_EMPTY.repeat(length - filled), unknown: false };
130
+ }
131
+
132
+ export function sanitizeStatusText(text: unknown): string {
133
+ if (typeof text !== "string") return "";
134
+ return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
135
+ }
136
+
137
+ /** 外部插件写入的状态自带 ANSI 着色,解析前先剥掉。 */
138
+ export function stripAnsi(text: unknown): string {
139
+ return String(text ?? "").replace(/\u001B\[[0-9;]*m/g, "");
140
+ }
141
+
142
+ /** 识别 pi-mcp-adapter 状态:compact "MCP 1/2" 或 full "🔌 MCP: N servers enabled (M connected)"。 */
143
+ export function parseMcpStatus(text: unknown): McpStatus | undefined {
144
+ const raw = stripAnsi(sanitizeStatusText(text));
145
+ const compact = raw.match(/^MCP (\d+)\/(\d+)$/);
146
+ if (compact) return { connected: Number(compact[1]), enabled: Number(compact[2]) };
147
+ const full = raw.match(/^(?:🔌 )?MCP: (\d+) servers? enabled(?: \((\d+) connected\))?(?: \((\d+) disabled\))?$/);
148
+ if (full) return { connected: full[2] !== undefined ? Number(full[2]) : 0, enabled: Number(full[1]) };
149
+ return undefined;
150
+ }
151
+
152
+ /**
153
+ * 识别 pi-lens 的 LSP 状态段:"LSP Active: a, b" / "LSP Failed: x" / "LSP Inactive",
154
+ * Active 与 Failed 可能以 " · " 合并在同一条状态里。Inactive 返回空数组(无活动不显示)。
155
+ */
156
+ export function parseLspStatus(text: unknown): LspChip[] | undefined {
157
+ const raw = stripAnsi(sanitizeStatusText(text));
158
+ if (raw === "LSP Inactive") return [];
159
+ const chips: LspChip[] = [];
160
+ for (const segment of raw.split(" · ")) {
161
+ const match = segment.trim().match(/^LSP (Active|Failed): (.+)$/);
162
+ if (!match) return undefined;
163
+ chips.push({ failed: match[1] === "Failed", names: match[2].trim() });
164
+ }
165
+ return chips;
166
+ }
package/index.ts ADDED
@@ -0,0 +1,465 @@
1
+ import type { ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ import {
5
+ LEGEND_LINES,
6
+ formatCacheHitRatio,
7
+ formatContext,
8
+ formatCost,
9
+ formatDuration,
10
+ formatSpeed,
11
+ formatTokens,
12
+ getModelIcon,
13
+ parseMcpStatus,
14
+ parseLspStatus,
15
+ sanitizeStatusText,
16
+ contextBarParts,
17
+ normalizeContextPercent,
18
+ splitProjectPath,
19
+ } from "./format.ts";
20
+
21
+ /** 字段开关:改这里即可定制 footer,无需动渲染逻辑。 */
22
+ export const CONFIG = {
23
+ showProject: true, // 项目路径(上级目录弱化,主目录缩写为 ~)
24
+ showSessionName: true, // 会话名(仅在 /session name 设置后出现)
25
+ showDuration: true, // 会话活跃跨度(首条消息 → 最新消息)
26
+ showTurns: true, // 交互轮次(assistant 消息数)
27
+ showSpeed: true, // 流式生成速率(tok/s,最近一次响应)
28
+ showBranch: true, // git 分支
29
+ showCacheRatio: true, // 缓存命中率
30
+ };
31
+
32
+ const LEGEND_WIDGET_KEY = "pi-signal-footer-legend";
33
+ const WIDE_LAYOUT_WIDTH = 112;
34
+ const MEDIUM_LAYOUT_WIDTH = 76;
35
+ const MIN_CONTEXT_BAR = 3;
36
+ const MAX_CONTEXT_BAR = 20;
37
+ const CONTEXT_WARNING_PERCENT = 50;
38
+ const CONTEXT_ERROR_PERCENT = 75;
39
+
40
+ type ContextColor = "accent" | "warning" | "error" | "muted";
41
+
42
+ type UsageLike = {
43
+ input?: number;
44
+ output?: number;
45
+ cacheRead?: number;
46
+ cacheWrite?: number;
47
+ cost?: { total?: number };
48
+ };
49
+
50
+ type UsageTotals = Required<Omit<UsageLike, "cost">> & { cost: number };
51
+
52
+ type SessionStats = { firstTs: number; lastTs: number; turns: number };
53
+ type SessionEntries = ReturnType<ExtensionContext["sessionManager"]["getEntries"]>;
54
+ type SessionEntry = SessionEntries[number];
55
+
56
+ // 流式速率计时:message_start 记请求时刻,首个 message_update 记首 token 时刻
57
+ // (剔除 TTFT/排队),message_end 用精确 usage.output 收口。
58
+ const streamState: { timing: { tRequest: number; tFirst: number } | null; lastRate: string } = {
59
+ timing: null,
60
+ lastRate: "",
61
+ };
62
+
63
+ function resetStreamState(): void {
64
+ streamState.timing = null;
65
+ streamState.lastRate = "";
66
+ }
67
+
68
+ function createUsageTotals(): UsageTotals {
69
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
70
+ }
71
+
72
+ function addUsage(totals: UsageTotals, usage: UsageLike | undefined): void {
73
+ if (!usage) return;
74
+
75
+ const finiteNonNegative = (value: unknown): number =>
76
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
77
+
78
+ totals.input += finiteNonNegative(usage.input);
79
+ totals.output += finiteNonNegative(usage.output);
80
+ totals.cacheRead += finiteNonNegative(usage.cacheRead);
81
+ totals.cacheWrite += finiteNonNegative(usage.cacheWrite);
82
+ totals.cost += finiteNonNegative(usage.cost?.total);
83
+ }
84
+
85
+ function entryUsage(entry: SessionEntry | undefined): UsageLike | undefined {
86
+ if (!entry) return undefined;
87
+ if (entry.type === "message") {
88
+ if (entry.message.role !== "assistant" && entry.message.role !== "toolResult") return undefined;
89
+ return (entry.message as { usage?: UsageLike }).usage;
90
+ }
91
+ if (entry.type === "branch_summary" || entry.type === "compaction") {
92
+ return entry.usage as UsageLike | undefined;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ // 求和口径与 pi 官方一致(core/getUsageCostBreakdown 同样四类全加):assistant/toolResult/
98
+ // branch_summary/compaction 的 usage 都是各次请求的增量,其中 branch_summary、compaction
99
+ // 记录的是摘要生成那次 LLM 调用自身的 usage,直接累加不会重复计数。
100
+ function computeUsageTotals(entries: SessionEntries): UsageTotals {
101
+ const totals = createUsageTotals();
102
+
103
+ for (const entry of entries) {
104
+ addUsage(totals, entryUsage(entry));
105
+ }
106
+
107
+ return totals;
108
+ }
109
+
110
+ function computeSessionStats(entries: SessionEntries): SessionStats {
111
+ const stats: SessionStats = { firstTs: Number.NaN, lastTs: Number.NaN, turns: 0 };
112
+
113
+ for (const entry of entries) {
114
+ const ts = Date.parse((entry as { timestamp?: string }).timestamp ?? "");
115
+ if (Number.isFinite(ts)) {
116
+ stats.firstTs = Number.isNaN(stats.firstTs) ? ts : Math.min(stats.firstTs, ts);
117
+ stats.lastTs = Number.isNaN(stats.lastTs) ? ts : Math.max(stats.lastTs, ts);
118
+ }
119
+ if (entry.type === "message" && entry.message.role === "assistant") stats.turns++;
120
+ }
121
+
122
+ return stats;
123
+ }
124
+
125
+ function usageSignature(usage: UsageLike | undefined): string {
126
+ if (!usage) return "";
127
+ return [usage.input, usage.output, usage.cacheRead, usage.cacheWrite, usage.cost?.total]
128
+ .map((value) => (Number.isFinite(value) ? value : ""))
129
+ .join(",");
130
+ }
131
+
132
+ /**
133
+ * Session entries can be updated in place while a response is finalized. Include
134
+ * the leaf fields used by the footer so those updates invalidate derived totals
135
+ * without scanning the entire session on every render. In-place edits to
136
+ * non-final entries are not detected; totals catch up on the next append.
137
+ */
138
+ function entrySignature(entry: SessionEntry | undefined): string {
139
+ if (!entry) return "";
140
+ const timestamp = (entry as { timestamp?: string }).timestamp ?? "";
141
+ const role = entry.type === "message" ? entry.message.role : "";
142
+ return `${entry.type}|${timestamp}|${role}|${usageSignature(entryUsage(entry))}`;
143
+ }
144
+
145
+ function entriesCacheKey(entries: SessionEntries): string {
146
+ return `${entries.length}|${entrySignature(entries.at(-1))}`;
147
+ }
148
+
149
+ // 色彩语义(全部取自 pi 主题,随 dark/light 切换):
150
+ // 图标/分隔/轨道 = muted·dim,数值 = text,身份(provider/模型)= accent·text,
151
+ // 钱 = warning,上下文 = 阈值变色(accent → warning → error)。
152
+
153
+ function contextColor(percent: number | undefined): ContextColor {
154
+ if (percent === undefined) return "muted";
155
+ if (percent >= CONTEXT_ERROR_PERCENT) return "error";
156
+ if (percent >= CONTEXT_WARNING_PERCENT) return "warning";
157
+ return "accent";
158
+ }
159
+
160
+ /**
161
+ * 上下文字段自适应宽度:budget 是本行分给它的列数。
162
+ * 降级阶梯(信息价值排序):条只是装饰,先丢;百分比与数值最后丢。
163
+ */
164
+ function contextField(ctx: ExtensionContext, theme: Theme, budget: number): string {
165
+ const usage = ctx.getContextUsage();
166
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
167
+ const percent = normalizeContextPercent(usage?.percent);
168
+ const icon = theme.fg("muted", "⎔");
169
+
170
+ if (percent === undefined) {
171
+ return `${icon} ${theme.fg("muted", formatContext(undefined, contextWindow))}`;
172
+ }
173
+
174
+ const color = contextColor(percent);
175
+ const colorFn = (t: string) => theme.fg(color, t);
176
+ const head = `${icon} ${colorFn(`${Math.min(100, Math.round(percent))}%`)}`;
177
+ const numbers = colorFn(formatContext(usage?.tokens, contextWindow));
178
+ // 预算扣除:head、numbers,加上前后两个空格与条自身的一对方括号
179
+ const barWidth = Math.min(MAX_CONTEXT_BAR, budget - visibleWidth(head) - visibleWidth(numbers) - 4);
180
+
181
+ if (barWidth < MIN_CONTEXT_BAR) return `${head} ${numbers}`;
182
+ const { fill, track } = contextBarParts(percent, barWidth);
183
+ const bar = `${theme.fg("muted", "[")}${colorFn(fill)}${theme.fg("dim", track)}${theme.fg("muted", "]")}`;
184
+ return `${head} ${bar} ${numbers}`;
185
+ }
186
+
187
+ function modelField(ctx: ExtensionContext, theme: Theme, footerData: ReadonlyFooterDataProvider): string {
188
+ const provider = ctx.model?.provider?.trim() ?? "";
189
+ const model = ctx.model?.id ?? "no-model";
190
+ const modelIcon = getModelIcon(model, provider);
191
+ const pipe = theme.fg("muted", " │ ");
192
+
193
+ const separator = theme.fg("muted", "›");
194
+ const modelText = `${theme.fg("accent", modelIcon)} ${theme.fg("text", model)}`;
195
+
196
+ let modelSection = modelText;
197
+ if (provider) {
198
+ modelSection = `${theme.fg("accent", provider)} ${separator} ${modelText}`;
199
+ }
200
+
201
+ const parts = [modelSection];
202
+
203
+ if (ctx.model?.reasoning) {
204
+ const level = ctx.thinkingLevel ?? "off";
205
+ if (level !== "off") {
206
+ parts.push(`${theme.fg("muted", "✦")} ${theme.getThinkingBorderColor(level)(level)}`);
207
+ }
208
+ }
209
+
210
+ const branch = footerData.getGitBranch();
211
+ if (CONFIG.showBranch && branch) {
212
+ parts.push(theme.fg("muted", `⎇ ${branch}`));
213
+ }
214
+
215
+ return parts.join(pipe);
216
+ }
217
+
218
+ /**
219
+ * 扩展状态槽(右下角):识别 pi-mcp-adapter / pi-lens 的已知文案后按本插件色板重排,
220
+ * 未知文案原样放行(保留源插件着色),对方改版时只会退化为原文而不会崩。
221
+ */
222
+ function statusField(footerData: ReadonlyFooterDataProvider, theme: Theme): string | undefined {
223
+ const entries = Array.from(footerData.getExtensionStatuses().entries())
224
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
225
+
226
+ const chips: string[] = [];
227
+ for (const [, text] of entries) {
228
+ const clean = sanitizeStatusText(text);
229
+ if (!clean) continue;
230
+
231
+ const mcp = parseMcpStatus(clean);
232
+ if (mcp) {
233
+ if (mcp.enabled > 0) {
234
+ // 懒连接服务器闲置时 0 连接属正常,全未连用中性灰而不是故障红
235
+ const color = mcp.connected === 0 ? "muted" : mcp.connected < mcp.enabled ? "warning" : "text";
236
+ chips.push(`${theme.fg("muted", "⇄ MCP")} ${theme.fg(color, `${mcp.connected}/${mcp.enabled}`)}`);
237
+ }
238
+ continue;
239
+ }
240
+
241
+ const lsp = parseLspStatus(clean);
242
+ if (lsp) {
243
+ for (const chip of lsp) {
244
+ chips.push(
245
+ chip.failed
246
+ ? theme.fg("error", `LSP ✗ ${chip.names}`)
247
+ : `${theme.fg("muted", "LSP")} ${theme.fg("text", chip.names)}`,
248
+ );
249
+ }
250
+ continue;
251
+ }
252
+
253
+ chips.push(clean);
254
+ }
255
+
256
+ if (chips.length === 0) return undefined;
257
+ return chips.join(theme.fg("dim", " · "));
258
+ }
259
+
260
+ function truncate(value: string, width: number, theme: Theme): string {
261
+ return truncateToWidth(value, Math.max(1, width), theme.fg("dim", "..."));
262
+ }
263
+
264
+ /**
265
+ * 一行放 left + right:宽裕时右对齐;紧张时被保留的一侧整块不动,另一侧截断。
266
+ * 默认保留 right(数据块),protectLeft 用于身份字段(模型)优先的场景。
267
+ */
268
+ function fitLine(left: string, right: string, width: number, theme: Theme, keepRight = true): string {
269
+ const kept = keepRight ? right : left;
270
+ const shrunk = keepRight ? left : right;
271
+ const gap = width - visibleWidth(left) - visibleWidth(right);
272
+
273
+ if (gap >= 2) return keepRight ? `${shrunk}${" ".repeat(gap)}${kept}` : `${kept}${" ".repeat(gap)}${shrunk}`;
274
+
275
+ const shrunkBudget = width - visibleWidth(kept) - 2;
276
+ if (shrunkBudget >= 1) {
277
+ return keepRight
278
+ ? `${truncate(shrunk, shrunkBudget, theme)} ${kept}`
279
+ : `${kept} ${truncate(shrunk, shrunkBudget, theme)}`;
280
+ }
281
+ return truncate(kept, width, theme);
282
+ }
283
+
284
+ function renderFooter(
285
+ ctx: ExtensionContext,
286
+ footerData: ReadonlyFooterDataProvider,
287
+ theme: Theme,
288
+ width: number,
289
+ totals: UsageTotals,
290
+ session: SessionStats,
291
+ ): string[] {
292
+ const pipe = theme.fg("muted", " │ ");
293
+
294
+ const input = `${theme.fg("muted", "↓")} ${theme.fg("text", formatTokens(totals.input))}`;
295
+ const output = `${theme.fg("muted", "↑")} ${theme.fg("text", formatTokens(totals.output))}`;
296
+
297
+ const hitRatio = CONFIG.showCacheRatio && totals.cacheWrite > 0 && totals.cacheRead > 0
298
+ ? formatCacheHitRatio(totals.cacheRead, totals.cacheWrite)
299
+ : undefined;
300
+ const cacheReadNum = `${theme.fg("text", formatTokens(totals.cacheRead))}${hitRatio ? theme.fg("muted", ` (${hitRatio})`) : ""}`;
301
+ const cacheRead = `${theme.fg("muted", "↻")} ${cacheReadNum}`;
302
+ const cacheWrite = `${theme.fg("muted", "✎")} ${theme.fg("text", formatTokens(totals.cacheWrite))}`;
303
+ const cost = theme.fg("warning", formatCost(totals.cost));
304
+ const model = modelField(ctx, theme, footerData);
305
+ const statuses = statusField(footerData, theme);
306
+
307
+ const timeParts: string[] = [];
308
+ if (CONFIG.showDuration && Number.isFinite(session.firstTs) && Number.isFinite(session.lastTs)) {
309
+ timeParts.push(theme.fg("text", formatDuration(session.lastTs - session.firstTs)));
310
+ }
311
+ if (CONFIG.showTurns && session.turns > 0) {
312
+ timeParts.push(theme.fg("text", `${session.turns}轮`));
313
+ }
314
+ if (CONFIG.showSpeed && streamState.lastRate) {
315
+ timeParts.push(theme.fg("text", streamState.lastRate));
316
+ }
317
+ const timeGroup = timeParts.length > 0
318
+ ? `${theme.fg("muted", "◷")} ${timeParts.join(theme.fg("muted", " · "))}`
319
+ : "";
320
+
321
+ const trafficGroup = `${input} ${output}`;
322
+ const cacheGroup = `${cacheRead} ${cacheWrite}`;
323
+ const stats = [trafficGroup, cacheGroup, cost, timeGroup].filter(Boolean).join(pipe);
324
+
325
+ // 项目槽位:完整路径——上级目录弱化、末级目录加粗、主目录缩写为 ~(可选链防旧版 pi 缺方法)
326
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
327
+ const { parent, name } = splitProjectPath(ctx.sessionManager.getCwd?.() ?? "", home);
328
+ const sessionName = name && CONFIG.showSessionName ? ctx.sessionManager.getSessionName?.() : undefined;
329
+ let projectSection = "";
330
+ if (CONFIG.showProject && name) {
331
+ projectSection = `${parent ? theme.fg("muted", parent) : ""}${theme.bold(theme.fg("text", name))}`;
332
+ if (sessionName) projectSection += theme.fg("muted", ` · ${sessionName}`);
333
+ }
334
+ let identityLeft = model;
335
+ if (projectSection) {
336
+ identityLeft = `${projectSection} ${pipe} ${model}`;
337
+ }
338
+
339
+ if (width >= WIDE_LAYOUT_WIDTH) {
340
+ const line1Right = contextField(ctx, theme, width - visibleWidth(identityLeft) - 2);
341
+ const line1 = fitLine(identityLeft, line1Right, width, theme, true);
342
+ const line2 = statuses ? fitLine(stats, statuses, width, theme, false) : truncate(stats, width, theme);
343
+ return [line1, line2];
344
+ }
345
+
346
+ if (width >= MEDIUM_LAYOUT_WIDTH) {
347
+ const line1Right = contextField(ctx, theme, width - visibleWidth(identityLeft) - 2);
348
+ const line1 = fitLine(identityLeft, line1Right, width, theme, true);
349
+ const line2 = truncate(stats, width, theme);
350
+ return [
351
+ line1,
352
+ line2,
353
+ ...(statuses ? [truncate(statuses, width, theme)] : []),
354
+ ];
355
+ }
356
+
357
+ return [
358
+ truncate(identityLeft, width, theme),
359
+ truncate(contextField(ctx, theme, width), width, theme),
360
+ truncate(trafficGroup, width, theme),
361
+ truncate(cacheGroup, width, theme),
362
+ truncate([cost, timeGroup].filter(Boolean).join(pipe), width, theme),
363
+ ...(statuses ? [truncate(statuses, width, theme)] : []),
364
+ ];
365
+ }
366
+
367
+ function installFooter(ctx: ExtensionContext): void {
368
+ let cachedEntryKey = "";
369
+ let cachedTotals = createUsageTotals();
370
+ let cachedSession: SessionStats = { firstTs: Number.NaN, lastTs: Number.NaN, turns: 0 };
371
+
372
+ ctx.ui.setFooter((tui, theme, footerData) => {
373
+ const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
374
+
375
+ return {
376
+ dispose: unsubscribe,
377
+ invalidate() {},
378
+ render(width: number): string[] {
379
+ const entries = ctx.sessionManager.getEntries();
380
+ const key = entriesCacheKey(entries);
381
+ if (key !== cachedEntryKey) {
382
+ cachedTotals = computeUsageTotals(entries);
383
+ cachedSession = computeSessionStats(entries);
384
+ cachedEntryKey = key;
385
+ }
386
+ return renderFooter(ctx, footerData, theme, width, cachedTotals, cachedSession);
387
+ },
388
+ };
389
+ });
390
+ }
391
+
392
+ function showLegend(ctx: ExtensionContext): void {
393
+ ctx.ui.setWidget(LEGEND_WIDGET_KEY, LEGEND_LINES, { placement: "aboveEditor" });
394
+ }
395
+
396
+ export default function (pi: ExtensionAPI): void {
397
+ pi.on("message_start", (event) => {
398
+ if (event.message.role !== "assistant") return;
399
+ streamState.timing = { tRequest: Date.now(), tFirst: 0 };
400
+ streamState.lastRate = "";
401
+ });
402
+
403
+ pi.on("message_update", (event) => {
404
+ if (event.message.role !== "assistant") return;
405
+ if (streamState.timing && !streamState.timing.tFirst) streamState.timing.tFirst = Date.now();
406
+ });
407
+
408
+ pi.on("message_end", (event) => {
409
+ if (!streamState.timing || event.message.role !== "assistant") return;
410
+ const usage = (event.message as { usage?: { output?: number } }).usage;
411
+ const start = streamState.timing.tFirst || streamState.timing.tRequest;
412
+ const ms = Date.now() - start;
413
+ streamState.timing = null;
414
+ if (usage?.output && ms > 0) streamState.lastRate = formatSpeed(usage.output, ms);
415
+ });
416
+
417
+ pi.on("session_start", async (_event, ctx) => {
418
+ resetStreamState();
419
+ installFooter(ctx);
420
+ });
421
+
422
+ // resources_discover 到来时 ctx 可能已刷新,重建 footer 是安全的:
423
+ // pi 的 setExtensionFooter 会先 dispose 旧组件(含 onBranchChange 退订)。
424
+ pi.on("resources_discover", async (_event, ctx) => {
425
+ installFooter(ctx);
426
+ });
427
+
428
+ pi.on("session_shutdown", async (_event, ctx) => {
429
+ resetStreamState();
430
+ ctx.ui.setWidget(LEGEND_WIDGET_KEY, undefined);
431
+ ctx.ui.setFooter(undefined);
432
+ });
433
+
434
+ pi.registerCommand("signal-footer", {
435
+ description: "Show the status legend or control the readable footer",
436
+ handler: async (args, ctx) => {
437
+ const action = args.trim().toLowerCase();
438
+
439
+ if (action === "" || action === "legend" || action === "help") {
440
+ showLegend(ctx);
441
+ return;
442
+ }
443
+
444
+ if (action === "hide") {
445
+ ctx.ui.setWidget(LEGEND_WIDGET_KEY, undefined);
446
+ return;
447
+ }
448
+
449
+ if (action === "off") {
450
+ ctx.ui.setWidget(LEGEND_WIDGET_KEY, undefined);
451
+ ctx.ui.setFooter(undefined);
452
+ ctx.ui.notify("本会话已临时切回 Pi 原生状态栏。", "info");
453
+ return;
454
+ }
455
+
456
+ if (action === "on") {
457
+ installFooter(ctx);
458
+ ctx.ui.notify("本会话已启用可读状态栏。", "info");
459
+ return;
460
+ }
461
+
462
+ ctx.ui.notify("用法: /signal-footer [legend|hide|off|on]", "warning");
463
+ },
464
+ });
465
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "pi-signal-footer",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "description": "A readable, responsive two-line status footer for Pi Coding Agent: model identity, token/cache/cost stats, adaptive context bar, session time and extension statuses.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/chengzhi-c/pi-signal-footer.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/chengzhi-c/pi-signal-footer/issues"
13
+ },
14
+ "homepage": "https://github.com/chengzhi-c/pi-signal-footer#readme",
15
+ "engines": {
16
+ "node": ">=22.19.0"
17
+ },
18
+ "files": [
19
+ "LICENSE",
20
+ "README.md",
21
+ "format.ts",
22
+ "index.ts"
23
+ ],
24
+ "keywords": [
25
+ "pi-package",
26
+ "pi-extension",
27
+ "footer",
28
+ "statusline",
29
+ "tui"
30
+ ],
31
+ "scripts": {
32
+ "test": "tsx --test test/format.test.mjs test/index.test.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "check": "npm run test && npm run typecheck",
35
+ "pack:check": "npm pack --dry-run"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./index.ts"
40
+ ]
41
+ },
42
+ "devDependencies": {
43
+ "@earendil-works/pi-coding-agent": "^0.84.4",
44
+ "@earendil-works/pi-tui": "^0.84.4",
45
+ "@types/node": "^26.4.0",
46
+ "tsx": "^4.23.12",
47
+ "typescript": "^7.0.2"
48
+ }
49
+ }