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 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
+ }