@pi-unipi/footer 2.1.0 → 2.1.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/package.json +3 -3
- package/src/index.ts +71 -4
- package/src/tps-tracker.ts +109 -33
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/footer",
|
|
3
|
-
"version": "2.1.
|
|
4
|
-
"description": "Persistent status bar for Unipi
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "2.1.
|
|
35
|
+
"@pi-unipi/core": "2.1.1"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
package/src/index.ts
CHANGED
|
@@ -96,6 +96,14 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
96
96
|
state.registry.registerGroup(group);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// ─── TPS streaming-event hooks (registered once) ────────────────────────
|
|
100
|
+
// pi.on() has no unsubscribe, so we register these exactly once at factory
|
|
101
|
+
// time (not per session_start) to avoid duplicate handlers accumulating
|
|
102
|
+
// across session restarts. The streamingIndex counter is reset on each
|
|
103
|
+
// session_shutdown. These hooks feed the TPS tracker in real time; the
|
|
104
|
+
// 1s branch-scan in the refresh timer only reconciles persisted messages.
|
|
105
|
+
wireTpsStreamingEvents(pi);
|
|
106
|
+
|
|
99
107
|
// ─── Session lifecycle ──────────────────────────────────────────────────
|
|
100
108
|
|
|
101
109
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -127,6 +135,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
127
135
|
}
|
|
128
136
|
state.tuiRef = null;
|
|
129
137
|
tpsTracker.reset();
|
|
138
|
+
resetTpsStreamingIndex();
|
|
130
139
|
});
|
|
131
140
|
|
|
132
141
|
// ─── Register commands ──────────────────────────────────────────────────
|
|
@@ -152,10 +161,14 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
152
161
|
ctx.ui.setFooter((tui, _theme, footerData) => {
|
|
153
162
|
state.tuiRef = tui;
|
|
154
163
|
|
|
155
|
-
// Start periodic refresh for time-sensitive segments (e.g. clock)
|
|
164
|
+
// Start periodic refresh for time-sensitive segments (e.g. clock, TPS)
|
|
156
165
|
if (!state.refreshTimer) {
|
|
157
166
|
state.refreshTimer = setInterval(() => {
|
|
158
|
-
//
|
|
167
|
+
// Re-seed TPS tracker from the session branch on each tick.
|
|
168
|
+
// Streaming events (message_start/update/end) handle live updates
|
|
169
|
+
// in real time; this scan reconciles the tracker with persisted
|
|
170
|
+
// messages after compactions, branch switches, or session reloads
|
|
171
|
+
// where in-flight streaming state may have been lost.
|
|
159
172
|
try {
|
|
160
173
|
const piCtx = state.piContext as Record<string, unknown> | undefined;
|
|
161
174
|
if (piCtx?.sessionManager) {
|
|
@@ -168,9 +181,10 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
168
181
|
const m = e.message;
|
|
169
182
|
if (!m || m.role !== "assistant") continue;
|
|
170
183
|
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
|
|
171
|
-
const output = m.usage?.output ?? 0;
|
|
172
184
|
const hasStop = !!m.stopReason;
|
|
173
|
-
|
|
185
|
+
// Pass the whole message: TPS tracker counts tokens from content,
|
|
186
|
+
// not from usage.output (which is 0 during streaming).
|
|
187
|
+
tpsTracker.onMessageUpdate(msgIndex, m, hasStop);
|
|
174
188
|
msgIndex++;
|
|
175
189
|
}
|
|
176
190
|
}
|
|
@@ -251,3 +265,56 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
|
|
|
251
265
|
};
|
|
252
266
|
}, { placement: "belowEditor" });
|
|
253
267
|
}
|
|
268
|
+
|
|
269
|
+
// ─── TPS streaming-event hooks ──────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sequential index of the currently-streaming assistant message. Tracked
|
|
273
|
+
* locally because pi does not expose a stable message index on streaming
|
|
274
|
+
* events, and the TPS tracker keys records off this index.
|
|
275
|
+
*/
|
|
276
|
+
let tpsStreamingIndex = -1;
|
|
277
|
+
|
|
278
|
+
/** Reset the streaming index (called on session_shutdown). */
|
|
279
|
+
function resetTpsStreamingIndex(): void {
|
|
280
|
+
tpsStreamingIndex = -1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Subscribe to pi's message streaming events and feed the TPS tracker in real
|
|
285
|
+
* time. This complements the 1s branch-scan in the refresh timer, which only
|
|
286
|
+
* sees persisted (completed) messages. Without these hooks the tracker would
|
|
287
|
+
* never observe an in-flight assistant message, so live TPS would stay frozen
|
|
288
|
+
* at the last completed message's value.
|
|
289
|
+
*
|
|
290
|
+
* Registered once at extension-factory time (pi.on has no unsubscribe, so we
|
|
291
|
+
* must not re-register per session_start or handlers would accumulate).
|
|
292
|
+
*/
|
|
293
|
+
function wireTpsStreamingEvents(pi: ExtensionAPI): void {
|
|
294
|
+
const safe = (fn: () => void) => {
|
|
295
|
+
try { fn(); } catch { /* TPS is best-effort */ }
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
pi.on("message_start", ((event: { message: unknown }) => safe(() => {
|
|
299
|
+
const m = event.message as Record<string, unknown> | undefined;
|
|
300
|
+
if (!m || m.role !== "assistant") return;
|
|
301
|
+
if (m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
302
|
+
tpsStreamingIndex++;
|
|
303
|
+
tpsTracker.onMessageUpdate(tpsStreamingIndex, m, false);
|
|
304
|
+
})) as (event: unknown) => void);
|
|
305
|
+
|
|
306
|
+
pi.on("message_update", ((event: { message: unknown }) => safe(() => {
|
|
307
|
+
if (tpsStreamingIndex < 0) return;
|
|
308
|
+
const m = event.message as Record<string, unknown> | undefined;
|
|
309
|
+
if (!m || m.role !== "assistant") return;
|
|
310
|
+
tpsTracker.onMessageUpdate(tpsStreamingIndex, m, false);
|
|
311
|
+
})) as (event: unknown) => void);
|
|
312
|
+
|
|
313
|
+
pi.on("message_end", ((event: { message: unknown }) => safe(() => {
|
|
314
|
+
if (tpsStreamingIndex < 0) return;
|
|
315
|
+
const m = event.message as Record<string, unknown> | undefined;
|
|
316
|
+
if (!m || m.role !== "assistant") return;
|
|
317
|
+
if (m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
318
|
+
tpsTracker.onMessageUpdate(tpsStreamingIndex, m, true);
|
|
319
|
+
})) as (event: unknown) => void);
|
|
320
|
+
}
|
package/src/tps-tracker.ts
CHANGED
|
@@ -4,37 +4,112 @@
|
|
|
4
4
|
* Per-message TPS calculation for live generation rate display.
|
|
5
5
|
* Tracks individual assistant messages with start/stop timestamps
|
|
6
6
|
* to measure generation rate excluding idle/tool-execution time.
|
|
7
|
+
*
|
|
8
|
+
* ## Token counting
|
|
9
|
+
*
|
|
10
|
+
* Output tokens are counted from the assistant message *content* (text +
|
|
11
|
+
* thinking blocks) via a lightweight word/char heuristic, NOT from
|
|
12
|
+
* `usage.output`. Rationale:
|
|
13
|
+
* - `usage.output` is only populated by the provider at stream end (or in
|
|
14
|
+
* a final `message_delta`), so it is 0 throughout generation and useless
|
|
15
|
+
* for a live rate.
|
|
16
|
+
* - Counting the streamed text ourselves gives a real, monotonically
|
|
17
|
+
* increasing numerator that reflects how much the model has actually
|
|
18
|
+
* produced so far.
|
|
19
|
+
* - This matches what users perceive as "tokens per second" (visible
|
|
20
|
+
* output rate) and avoids the previous bug where the displayed TPS was
|
|
21
|
+
* always inflated to 90+ t/s (caused by dividing a post-hoc `usage.output`
|
|
22
|
+
* total by a too-short, incorrectly measured elapsed window).
|
|
23
|
+
*
|
|
24
|
+
* The heuristic intentionally over-counts slightly (whitespace + punctuation
|
|
25
|
+
* are treated as tokens). That is acceptable for a live speed indicator and
|
|
26
|
+
* keeps the estimate in the right order of magnitude (within ~10-20% of the
|
|
27
|
+
* provider's reported output tokens for typical English/code output).
|
|
7
28
|
*/
|
|
8
29
|
|
|
9
30
|
/** Per-message TPS record */
|
|
10
31
|
interface MessageTpsRecord {
|
|
11
|
-
/** Message index in the session */
|
|
32
|
+
/** Message index in the session (sequential, assistant-only) */
|
|
12
33
|
messageIndex: number;
|
|
13
|
-
/**
|
|
34
|
+
/** Estimated output tokens produced so far for this message */
|
|
14
35
|
outputTokens: number;
|
|
15
|
-
/** When generation started (Date.now()) */
|
|
36
|
+
/** When generation started (Date.now(), ms) */
|
|
16
37
|
startedAt: number;
|
|
17
|
-
/** When generation completed (Date.now()), 0 if still generating */
|
|
38
|
+
/** When generation completed (Date.now(), ms), 0 if still generating */
|
|
18
39
|
completedAt: number;
|
|
19
|
-
/** Computed TPS for this message */
|
|
40
|
+
/** Computed TPS for this message (final, once completed) */
|
|
20
41
|
tps: number;
|
|
21
42
|
}
|
|
22
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Estimate the number of tokens in a string.
|
|
46
|
+
*
|
|
47
|
+
* Uses a cheap word + punctuation split. Good enough for a live TPS display;
|
|
48
|
+
* we deliberately avoid pulling in a full BPE tokenizer for performance and
|
|
49
|
+
* footprint reasons.
|
|
50
|
+
*/
|
|
51
|
+
function estimateTokens(text: string): number {
|
|
52
|
+
if (!text) return 0;
|
|
53
|
+
// Count word-ish tokens and standalone punctuation.
|
|
54
|
+
// \p{L} / \p{N} keep this Unicode-aware for non-ASCII content.
|
|
55
|
+
const wordish = text.match(/[\p{L}\p{N}]+/gu);
|
|
56
|
+
const punct = text.match(/[^\s\p{L}\p{N}]+/gu);
|
|
57
|
+
const wordCount = wordish ? wordish.length : 0;
|
|
58
|
+
const punctCount = punct ? punct.length : 0;
|
|
59
|
+
return wordCount + punctCount;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Estimate output tokens from an assistant message by summing text and
|
|
64
|
+
* thinking content lengths. Tool-call argument JSON is also counted since
|
|
65
|
+
* it represents model output.
|
|
66
|
+
*
|
|
67
|
+
* Accepts the raw message shape from pi's session entries (loosely typed).
|
|
68
|
+
*/
|
|
69
|
+
function estimateOutputTokens(message: unknown): number {
|
|
70
|
+
if (!message || typeof message !== "object") return 0;
|
|
71
|
+
const m = message as Record<string, unknown>;
|
|
72
|
+
if (m.role !== "assistant") return 0;
|
|
73
|
+
const content = m.content;
|
|
74
|
+
if (!Array.isArray(content)) return 0;
|
|
75
|
+
|
|
76
|
+
let total = 0;
|
|
77
|
+
for (const block of content) {
|
|
78
|
+
if (!block || typeof block !== "object") continue;
|
|
79
|
+
const b = block as Record<string, unknown>;
|
|
80
|
+
const type = b.type;
|
|
81
|
+
if (type === "text" && typeof b.text === "string") {
|
|
82
|
+
total += estimateTokens(b.text);
|
|
83
|
+
} else if (type === "thinking" && typeof b.thinking === "string") {
|
|
84
|
+
total += estimateTokens(b.thinking);
|
|
85
|
+
} else if (type === "tool_use" || type === "toolcall") {
|
|
86
|
+
// Count tool name + serialized arguments as model output.
|
|
87
|
+
const name = typeof b.name === "string" ? b.name : "";
|
|
88
|
+
const input = b.input ?? b.arguments;
|
|
89
|
+
let inputStr: string;
|
|
90
|
+
try {
|
|
91
|
+
inputStr = typeof input === "string" ? input : JSON.stringify(input ?? "");
|
|
92
|
+
} catch {
|
|
93
|
+
inputStr = "";
|
|
94
|
+
}
|
|
95
|
+
total += estimateTokens(name) + estimateTokens(inputStr);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return total;
|
|
99
|
+
}
|
|
100
|
+
|
|
23
101
|
/**
|
|
24
102
|
* Tracks per-message TPS and computes live/session metrics.
|
|
25
103
|
*
|
|
26
|
-
* Usage: Call `onMessageUpdate()`
|
|
27
|
-
* The tracker records generation start/stop
|
|
28
|
-
*
|
|
104
|
+
* Usage: Call `onMessageUpdate()` on every tick (e.g. 1s interval) with the
|
|
105
|
+
* current assistant message state. The tracker records generation start/stop
|
|
106
|
+
* per message and computes live TPS from elapsed wall-clock time.
|
|
29
107
|
*/
|
|
30
108
|
export class TpsTracker {
|
|
31
|
-
/** Per-message records */
|
|
109
|
+
/** Per-message records (one per assistant message, in order) */
|
|
32
110
|
private records: MessageTpsRecord[] = [];
|
|
33
111
|
|
|
34
|
-
/**
|
|
35
|
-
private lastSeenMessageCount = 0;
|
|
36
|
-
|
|
37
|
-
/** Total output tokens across all completed messages */
|
|
112
|
+
/** Total estimated output tokens across all messages */
|
|
38
113
|
private totalOutput = 0;
|
|
39
114
|
|
|
40
115
|
/**
|
|
@@ -42,15 +117,16 @@ export class TpsTracker {
|
|
|
42
117
|
* Call this on every tick (e.g. 1s interval) with the current state.
|
|
43
118
|
*
|
|
44
119
|
* @param messageIndex - Index of the assistant message (0-based, sequential)
|
|
45
|
-
* @param
|
|
120
|
+
* @param message - The assistant message object (content used to count tokens)
|
|
46
121
|
* @param hasStopReason - Whether this message has completed (has stopReason)
|
|
47
122
|
*/
|
|
48
|
-
onMessageUpdate(messageIndex: number,
|
|
123
|
+
onMessageUpdate(messageIndex: number, message: unknown, hasStopReason: boolean): void {
|
|
49
124
|
const now = Date.now();
|
|
125
|
+
const outputTokens = estimateOutputTokens(message);
|
|
50
126
|
|
|
51
127
|
// New message — create a record
|
|
52
128
|
if (messageIndex >= this.records.length) {
|
|
53
|
-
// Fill gaps if indices jump
|
|
129
|
+
// Fill gaps if indices jump (shouldn't normally happen)
|
|
54
130
|
while (this.records.length < messageIndex) {
|
|
55
131
|
this.records.push({
|
|
56
132
|
messageIndex: this.records.length,
|
|
@@ -62,11 +138,12 @@ export class TpsTracker {
|
|
|
62
138
|
}
|
|
63
139
|
|
|
64
140
|
if (hasStopReason && outputTokens > 0) {
|
|
65
|
-
// Fast message: already completed on first sighting
|
|
66
|
-
//
|
|
67
|
-
|
|
141
|
+
// Fast message: already completed on first sighting.
|
|
142
|
+
// We never observed it streaming, so we can't measure real duration.
|
|
143
|
+
// Fall back to a conservative estimate instead of inventing a 1s
|
|
144
|
+
// window (which previously produced inflated TPS values).
|
|
145
|
+
const estimatedDuration = Math.max(0.5, outputTokens / 60);
|
|
68
146
|
const tps = outputTokens / estimatedDuration;
|
|
69
|
-
|
|
70
147
|
this.records.push({
|
|
71
148
|
messageIndex,
|
|
72
149
|
outputTokens,
|
|
@@ -85,7 +162,6 @@ export class TpsTracker {
|
|
|
85
162
|
tps: 0,
|
|
86
163
|
});
|
|
87
164
|
}
|
|
88
|
-
this.lastSeenMessageCount = messageIndex + 1;
|
|
89
165
|
return;
|
|
90
166
|
}
|
|
91
167
|
|
|
@@ -93,23 +169,24 @@ export class TpsTracker {
|
|
|
93
169
|
const record = this.records[messageIndex];
|
|
94
170
|
if (!record) return;
|
|
95
171
|
|
|
96
|
-
record.
|
|
172
|
+
if (record.completedAt === 0) {
|
|
173
|
+
// Still generating — update token count (live TPS computed on demand)
|
|
174
|
+
record.outputTokens = outputTokens;
|
|
97
175
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Still generating — update output tokens (live TPS computed on demand)
|
|
176
|
+
if (hasStopReason) {
|
|
177
|
+
// Message just completed
|
|
178
|
+
record.completedAt = now;
|
|
179
|
+
const durationSec = (record.completedAt - record.startedAt) / 1000;
|
|
180
|
+
record.tps = durationSec > 0 ? record.outputTokens / durationSec : 0;
|
|
181
|
+
this.totalOutput += record.outputTokens;
|
|
182
|
+
}
|
|
106
183
|
}
|
|
107
184
|
}
|
|
108
185
|
|
|
109
186
|
/**
|
|
110
187
|
* Get the live TPS from the currently generating message.
|
|
111
188
|
* Returns the instantaneous rate based on tokens generated so far
|
|
112
|
-
* in the current message divided by elapsed time.
|
|
189
|
+
* in the current message divided by elapsed wall-clock time.
|
|
113
190
|
*/
|
|
114
191
|
getLiveTps(): number {
|
|
115
192
|
// Find the last record that's still generating
|
|
@@ -177,7 +254,7 @@ export class TpsTracker {
|
|
|
177
254
|
}
|
|
178
255
|
|
|
179
256
|
/**
|
|
180
|
-
* Get total output tokens for the session.
|
|
257
|
+
* Get total output tokens for the session (estimated).
|
|
181
258
|
*/
|
|
182
259
|
getTotalOutput(): number {
|
|
183
260
|
// Include tokens from incomplete messages too
|
|
@@ -195,7 +272,6 @@ export class TpsTracker {
|
|
|
195
272
|
*/
|
|
196
273
|
reset(): void {
|
|
197
274
|
this.records = [];
|
|
198
|
-
this.lastSeenMessageCount = 0;
|
|
199
275
|
this.totalOutput = 0;
|
|
200
276
|
}
|
|
201
277
|
}
|