pi-metrics-footer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/extensions/index.ts +182 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mingjian Shao
|
|
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,55 @@
|
|
|
1
|
+
# pi-metrics-footer
|
|
2
|
+
|
|
3
|
+
Live model speed and usage metrics in the pi TUI footer: tokens/sec while streaming, per-turn average speed, token/cost/context stats, and model name โ with configurable model labels.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Live tokens/sec** while the model streams (`โก 45t/s`), computed from a 2s rolling window of `text_delta` / `thinking_delta` events.
|
|
8
|
+
- **Per-turn average** as a fallback: after a reply finishes, the average speed shows for 8 seconds โ so it works even when the provider doesn't stream deltas.
|
|
9
|
+
- **Token / cost / context line**: input, output, cache read/write, cost, and context usage, plus the current model (emoji label + thinking level).
|
|
10
|
+
- **Configurable model labels**: map `provider/model` IDs to your own display names.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install npm:pi-metrics-footer
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then restart pi. The footer is replaced on session start.
|
|
19
|
+
|
|
20
|
+
## Update
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi update npm:pi-metrics-footer
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Updates are manual; the package does not auto-update.
|
|
27
|
+
|
|
28
|
+
## Uninstall
|
|
29
|
+
|
|
30
|
+
Remove `pi-metrics-footer` from the `packages` list in `~/.pi/agent/settings.json`, then restart pi.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Create `<pi-agent-dir>/pi-metrics-footer.json` (default: `~/.pi/agent/pi-metrics-footer.json`):
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"modelLabels": {
|
|
39
|
+
"anthropic/claude-sonnet-5": "๐ญ Sonnet 5",
|
|
40
|
+
"openai/gpt-5.5": "๐ง GPT-5.5"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Keys are `provider/model` exactly as shown by pi (e.g. from `/model`). User labels override the built-in defaults for the same key and add new ones. Restart pi (or `/reload`) after editing.
|
|
46
|
+
|
|
47
|
+
## How It Works
|
|
48
|
+
|
|
49
|
+
- **Live TPS** uses a 2s rolling window of streaming token events; it needs at least 2 samples within the window, so very short replies may not show it.
|
|
50
|
+
- **Average TPS** = output tokens รท wall time of the last finished assistant message, shown for 8s.
|
|
51
|
+
- When idle, nothing shows โ the indicator hides on its own.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* pi-metrics-footer
|
|
10
|
+
*
|
|
11
|
+
* Replaces the pi TUI footer with a metrics line:
|
|
12
|
+
* - live tokens/sec while streaming (2s rolling window),
|
|
13
|
+
* - average tokens/sec for the last finished turn (8s fallback, works even when
|
|
14
|
+
* the provider does not stream deltas),
|
|
15
|
+
* - input/output/cache/cost tokens, context usage, and the current model.
|
|
16
|
+
*
|
|
17
|
+
* Model display names come from a default map and can be overridden per user via
|
|
18
|
+
* `<agent-dir>/pi-metrics-footer.json`:
|
|
19
|
+
* { "modelLabels": { "anthropic/claude-sonnet-5": "๐ญ Sonnet 5" } }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CONFIG_FILE = "pi-metrics-footer.json";
|
|
23
|
+
|
|
24
|
+
/** Default labels for well-known public model IDs. Override via config. */
|
|
25
|
+
const DEFAULT_MODEL_LABELS: Record<string, string> = {
|
|
26
|
+
// Anthropic
|
|
27
|
+
"anthropic/claude-opus-4": "๐ฆ Opus 4",
|
|
28
|
+
"anthropic/claude-opus-4-1": "๐ฆ Opus 4.1",
|
|
29
|
+
"anthropic/claude-sonnet-4": "๐ญ Sonnet 4",
|
|
30
|
+
"anthropic/claude-sonnet-4-5": "๐ญ Sonnet 4.5",
|
|
31
|
+
"anthropic/claude-haiku-4-5": "๐ Haiku 4.5",
|
|
32
|
+
// OpenAI
|
|
33
|
+
"openai/gpt-5": "๐ง GPT-5",
|
|
34
|
+
"openai/gpt-5-mini": "โก GPT-5 Mini",
|
|
35
|
+
"openai/gpt-5-nano": "โก GPT-5 Nano",
|
|
36
|
+
// Google
|
|
37
|
+
"google/gemini-2.5-pro": "๐ Gemini Pro",
|
|
38
|
+
"google/gemini-2.5-flash": "๐ Gemini Flash",
|
|
39
|
+
// Others
|
|
40
|
+
"deepseek/deepseek-chat": "๐ DeepSeek",
|
|
41
|
+
"deepseek/deepseek-reasoner": "๐ DeepSeek R",
|
|
42
|
+
"qwen/qwen3-coder": "๐พ Qwen Coder",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function loadUserLabels(): Record<string, string> {
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(join(getAgentDir(), CONFIG_FILE), "utf8");
|
|
48
|
+
const parsed: unknown = JSON.parse(raw);
|
|
49
|
+
if (parsed && typeof parsed === "object" && "modelLabels" in parsed) {
|
|
50
|
+
const labels = (parsed as { modelLabels: unknown }).modelLabels;
|
|
51
|
+
if (labels && typeof labels === "object") return labels as Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// missing or unreadable config is fine; defaults apply
|
|
55
|
+
}
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const MODEL_LABELS: Record<string, string> = { ...DEFAULT_MODEL_LABELS, ...loadUserLabels() };
|
|
60
|
+
|
|
61
|
+
const ROLLING_WINDOW_MS = 2000;
|
|
62
|
+
const AVG_DISPLAY_MS = 8000;
|
|
63
|
+
const tokenTimestamps: number[] = [];
|
|
64
|
+
let messageStartTime = 0;
|
|
65
|
+
let lastAvgTps: number | null = null;
|
|
66
|
+
let lastAvgExpires = 0;
|
|
67
|
+
|
|
68
|
+
function recordStreamToken(): void {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
tokenTimestamps.push(now);
|
|
71
|
+
const cutoff = now - ROLLING_WINDOW_MS;
|
|
72
|
+
while (tokenTimestamps.length > 0 && tokenTimestamps[0]! < cutoff) {
|
|
73
|
+
tokenTimestamps.shift();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function computeStreamTps(): number | null {
|
|
78
|
+
const cutoff = Date.now() - ROLLING_WINDOW_MS;
|
|
79
|
+
while (tokenTimestamps.length > 0 && tokenTimestamps[0]! < cutoff) {
|
|
80
|
+
tokenTimestamps.shift();
|
|
81
|
+
}
|
|
82
|
+
if (tokenTimestamps.length < 2) return null;
|
|
83
|
+
const elapsed = tokenTimestamps[tokenTimestamps.length - 1]! - tokenTimestamps[0]!;
|
|
84
|
+
if (elapsed <= 0) return null;
|
|
85
|
+
return tokenTimestamps.length / (elapsed / 1000);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatTokens(tokens: number): string {
|
|
89
|
+
if (tokens < 1_000) return String(tokens);
|
|
90
|
+
if (tokens < 10_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
91
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
|
|
92
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function (pi: ExtensionAPI) {
|
|
96
|
+
pi.on("session_start", (_event, ctx) => {
|
|
97
|
+
if (ctx.mode !== "tui") return;
|
|
98
|
+
|
|
99
|
+
pi.on("message_start", (event) => {
|
|
100
|
+
if (event.message?.role === "assistant") {
|
|
101
|
+
tokenTimestamps.length = 0;
|
|
102
|
+
messageStartTime = Date.now();
|
|
103
|
+
lastAvgTps = null;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("message_update", (event) => {
|
|
108
|
+
const ev = event.assistantMessageEvent;
|
|
109
|
+
if (ev?.type === "text_delta" || ev?.type === "thinking_delta") {
|
|
110
|
+
recordStreamToken();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.on("message_end", (event) => {
|
|
115
|
+
if (event.message?.role !== "assistant") return;
|
|
116
|
+
const usage = (event.message as AssistantMessage).usage;
|
|
117
|
+
const output = usage?.output ?? 0;
|
|
118
|
+
const elapsed = Date.now() - messageStartTime;
|
|
119
|
+
if (output > 0 && elapsed > 400) {
|
|
120
|
+
lastAvgTps = output / (elapsed / 1000);
|
|
121
|
+
lastAvgExpires = Date.now() + AVG_DISPLAY_MS;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
ctx.ui.setFooter((_tui, theme, footerData) => {
|
|
126
|
+
const unsubscribe = footerData.onBranchChange(() => _tui.requestRender());
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
dispose: unsubscribe,
|
|
130
|
+
invalidate() {},
|
|
131
|
+
render(width: number): string[] {
|
|
132
|
+
let input = 0;
|
|
133
|
+
let output = 0;
|
|
134
|
+
let cacheRead = 0;
|
|
135
|
+
let cacheWrite = 0;
|
|
136
|
+
let cost = 0;
|
|
137
|
+
|
|
138
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
139
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
140
|
+
const message = entry.message as AssistantMessage;
|
|
141
|
+
input += message.usage.input;
|
|
142
|
+
output += message.usage.output;
|
|
143
|
+
cacheRead += message.usage.cacheRead;
|
|
144
|
+
cacheWrite += message.usage.cacheWrite;
|
|
145
|
+
cost += message.usage.cost.total;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const usage = ctx.getContextUsage();
|
|
149
|
+
const context = usage?.percent == null
|
|
150
|
+
? `?/${formatTokens(usage?.contextWindow ?? ctx.model?.contextWindow ?? 0)}`
|
|
151
|
+
: `${usage.percent.toFixed(1)}%/${formatTokens(usage.contextWindow)}`;
|
|
152
|
+
const leftParts = [`โ${formatTokens(input)}`, `โ${formatTokens(output)}`];
|
|
153
|
+
const live = computeStreamTps();
|
|
154
|
+
const avg = Date.now() < lastAvgExpires ? lastAvgTps : null;
|
|
155
|
+
const tps = live ?? avg;
|
|
156
|
+
if (tps !== null) {
|
|
157
|
+
leftParts.unshift(`โก ${tps.toFixed(0)}t/s`);
|
|
158
|
+
}
|
|
159
|
+
if (cacheRead) leftParts.push(`R${formatTokens(cacheRead)}`);
|
|
160
|
+
if (cacheWrite) leftParts.push(`W${formatTokens(cacheWrite)}`);
|
|
161
|
+
if (cost) leftParts.push(`$${cost.toFixed(3)}`);
|
|
162
|
+
leftParts.push(context);
|
|
163
|
+
const left = theme.fg("dim", leftParts.join(" "));
|
|
164
|
+
|
|
165
|
+
const model = ctx.model;
|
|
166
|
+
const modelKey = model ? `${model.provider}/${model.id}` : "";
|
|
167
|
+
const label = MODEL_LABELS[modelKey] ?? model?.name ?? model?.id ?? "no model";
|
|
168
|
+
const thinking = model?.reasoning ? ` ยท ${ctx.thinkingLevel ?? "off"}` : "";
|
|
169
|
+
const right = theme.fg("accent", `${label}${thinking}`);
|
|
170
|
+
const padding = " ".repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(right)));
|
|
171
|
+
|
|
172
|
+
const branch = footerData.getGitBranch();
|
|
173
|
+
const pwd = branch ? `${ctx.cwd} (${branch})` : ctx.cwd;
|
|
174
|
+
return [
|
|
175
|
+
truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")),
|
|
176
|
+
truncateToWidth(left + padding + right, width, ""),
|
|
177
|
+
];
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-metrics-footer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live model speed (tokens/s), token/cost/context stats, and configurable model labels in the pi TUI footer",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"extension",
|
|
9
|
+
"footer",
|
|
10
|
+
"metrics",
|
|
11
|
+
"tokens",
|
|
12
|
+
"tps",
|
|
13
|
+
"tokens-per-second"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": ["./extensions"]
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"extensions"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepublishOnly": "npm run typecheck"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-ai": "*",
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*",
|
|
34
|
+
"@types/node": "^22",
|
|
35
|
+
"typescript": "^5"
|
|
36
|
+
}
|
|
37
|
+
}
|