@pi-unipi/footer 2.1.0 → 2.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.1.0",
4
- "description": "Persistent status bar for Unipi \u2014 subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
3
+ "version": "2.1.2",
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.0"
35
+ "@pi-unipi/core": "2.1.2"
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
- // Feed TPS tracker with per-message data
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
- tpsTracker.onMessageUpdate(msgIndex, output, hasStop);
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
+ }
@@ -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
- /** Output tokens for this message */
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()` whenever output tokens change.
27
- * The tracker records generation start/stop per message and computes
28
- * live TPS from the current message and session averages excluding idle time.
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
- /** Highest message index seen so far (for dedup) */
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 outputTokens - Output tokens for this message
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, outputTokens: number, hasStopReason: boolean): void {
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
- // Estimate duration: floor of 1 second, or outputTokens/100, whichever is smaller
67
- const estimatedDuration = Math.max(1, outputTokens / 100);
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.outputTokens = outputTokens;
172
+ if (record.completedAt === 0) {
173
+ // Still generating — update token count (live TPS computed on demand)
174
+ record.outputTokens = outputTokens;
97
175
 
98
- if (record.completedAt === 0 && hasStopReason) {
99
- // Message just completed
100
- record.completedAt = now;
101
- const durationSec = (record.completedAt - record.startedAt) / 1000;
102
- record.tps = durationSec > 0 ? outputTokens / durationSec : outputTokens;
103
- this.totalOutput += outputTokens;
104
- } else if (record.completedAt === 0) {
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
  }