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 +21 -0
- package/README.md +84 -0
- package/README.zh-CN.md +84 -0
- package/format.ts +166 -0
- package/index.ts +465 -0
- package/package.json +49 -0
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)
|
package/README.zh-CN.md
ADDED
|
@@ -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
|
+
}
|