@pi-unipi/footer 2.12.0 → 2.13.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/README.md CHANGED
@@ -18,6 +18,7 @@ An experimental input surface, on by default and toggleable in `/unipi:footer-se
18
18
  - **Top border:** animated lolcat-gradient UNIPI brand + git branch (turns rainbow-frame animated while thinking is max/xhigh)
19
19
  - **Bottom border:** workspace · context %/window · model · thinking level
20
20
  - **Session strip:** turns/steps, wall + tool wall time, average TTFT, tok/s, cache hit % — colored per stat, honest across restarts (derived from persisted session timestamps when live hooks are unavailable; provider-reported `usage.output` anchors token counts whenever present)
21
+ - **Process line (new in 2.13):** centered one-liner directly above the frame while background work is in flight — `● 3 running ● 1 stopped ● 1 failed ● 2 done` — green ● running, yellow ● stopped (killed), red ● failed, gray ● done. Covers every task type (shell jobs, delegates, fusion workflows) via direct registry reads; zero-count buckets are omitted and the line hides when idle. Counts reset per session.
21
22
  - The classic segment status line is suppressed while glance mode is on; toggle it back for the classic footer
22
23
 
23
24
  ## Commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
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": "index.ts",
@@ -32,7 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.12.0"
35
+ "@pi-unipi/core": "2.12.0",
36
+ "@pi-unipi/background-tasks": "2.13.0"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import { STATUS_EXT_SEGMENTS } from "./segments/status-ext.js";
29
29
 
30
30
  import type { FooterGroup, FooterSegment } from "./types.js";
31
31
  import { tpsTracker } from "./tps-tracker.js";
32
+ import { renderProcessLine } from "./process-line.js";
32
33
 
33
34
  /** All segment groups */
34
35
  const ALL_GROUPS: FooterGroup[] = [
@@ -235,12 +236,6 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
235
236
  // Branch-derived tool time: pending callId → assistant msg ts.
236
237
  const pendingToolCalls = new Map<string, number>();
237
238
  let branchToolMs = 0;
238
- // Per-assistant-message duration (this assistant ts → next entry
239
- // ts): the honest generation+tool window, replaces first-scan
240
- // reconstruction for restart tok/s.
241
- const recordDurations = new Map<number, number>();
242
- let prevEntryTs = 0;
243
- let prevAssistantIdx = -1;
244
239
  for (const e of events) {
245
240
  if (!e || typeof e !== "object") continue;
246
241
  if (e.type !== "message") continue;
@@ -249,12 +244,6 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
249
244
  // Branch-derived turn/wall accounting: user messages delimit
250
245
  // turns; assistant timestamps bound the session wall time.
251
246
  const ts = Date.parse((e as any).timestamp ?? "") || 0;
252
- // Close the previous assistant's window with this entry's ts.
253
- if (prevAssistantIdx >= 0 && ts > 0 && prevEntryTs > 0) {
254
- recordDurations.set(prevAssistantIdx, Math.min(ts - prevEntryTs, 600_000));
255
- prevAssistantIdx = -1;
256
- }
257
- if (ts > 0) prevEntryTs = ts;
258
247
  if (m.role === "user") {
259
248
  userCount++;
260
249
  continue;
@@ -274,7 +263,6 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
274
263
  if (ts > 0) {
275
264
  if (firstAssistantTs === 0 || ts < firstAssistantTs) firstAssistantTs = ts;
276
265
  if (ts > lastAssistantTs) lastAssistantTs = ts;
277
- prevAssistantIdx = msgIndex; // close on next entry
278
266
  }
279
267
  const hasStop = !!m.stopReason;
280
268
  // Pass the whole message: completed messages get anchored to
@@ -301,7 +289,11 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
301
289
  msgIndex++;
302
290
  }
303
291
  tpsTracker.syncBranchStats(userCount, msgIndex);
304
- if (recordDurations.size > 0) tpsTracker.syncRecordDurations(recordDurations);
292
+ // NOTE: no per-record duration seeding here on purpose. Timestamps
293
+ // alone cannot mark stream END — a 'next-entry' delta would include
294
+ // tool runs + user think-time inside the rate window (the bug that
295
+ // made 100-tok/s models read ~7 tok/s). AVG now uses only
296
+ // hook-measured decode windows; see getSessionAvgTps().
305
297
  if (lastAssistantTs > firstAssistantTs) {
306
298
  tpsTracker.syncWallMs(lastAssistantTs - firstAssistantTs);
307
299
  }
@@ -337,9 +329,10 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
337
329
  };
338
330
  });
339
331
 
340
- // Top row widget — classic status line (suppressed in glance mode; the
341
- // glance frame's top border already shows UNIPI branch and the bottom
342
- // border shows context/model/thinking, so a segment row would duplicate it)
332
+ // Top row widget — dual role. Classic mode: status segment line. Glance
333
+ // mode: the bg-process one-liner, rendered directly above the glance frame
334
+ // (the frame replaces the editor, so this aboveEditor slot sits right above
335
+ // the footer); the frame's own borders show branch/context/model/thinking.
343
336
  ctx.ui.setWidget("footer-top", (_tui, theme) => {
344
337
  // Update the renderer's theme-like
345
338
  const themeLike = { fg: (color: string, text: string) => theme.fg(color as any, text) };
@@ -353,8 +346,8 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
353
346
  },
354
347
  render(width: number): string[] {
355
348
  if (!state.enabled || !state.piContext || width <= 0) return [];
356
- // Glance mode replaces the classic segment line entirely.
357
- if (state.glanceMode) return [];
349
+ // Glance mode: this slot becomes the bg-process one-liner.
350
+ if (state.glanceMode) return renderProcessLine(width);
358
351
  const layout = state.renderer.computeLayout(width);
359
352
  if (!layout.topContent) return [];
360
353
 
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @pi-unipi/footer — Background process one-liner
3
+ *
4
+ * Glance-mode strip rendered above the footer frame: one colored dot + count
5
+ * per background-task status. Reads DIRECTLY from the
6
+ * @pi-unipi/background-tasks shared registry (no events, no polling
7
+ * channels); re-renders on the footer's existing 1s refresh timer.
8
+ *
9
+ * Dot → status mapping:
10
+ * green ● running yellow ● stopped (killed) red ● failed gray ● done (completed)
11
+ *
12
+ * Buckets with zero count are omitted; with nothing in flight the line is
13
+ * empty so the footer stays clean.
14
+ */
15
+
16
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
+ import { getSharedTaskRegistry } from "@pi-unipi/background-tasks";
18
+
19
+ const GREEN_DOT = "\x1b[38;5;82m●\x1b[0m"; // running — active work
20
+ const YELLOW_DOT = "\x1b[38;5;220m●\x1b[0m"; // stopped (killed) — needs attention
21
+ const RED_DOT = "\x1b[38;5;196m●\x1b[0m"; // failed — needs attention
22
+ const GRAY_DOT = "\x1b[38;5;245m●\x1b[0m"; // done (completed) — idle info
23
+
24
+ export interface BgProcessCounts {
25
+ running: number;
26
+ stopped: number;
27
+ failed: number;
28
+ done: number;
29
+ }
30
+
31
+ /**
32
+ * Count background tasks by display status straight from the registry.
33
+ * Returns null when background-tasks has not published a registry (module
34
+ * disabled, before first load, or after session shutdown).
35
+ */
36
+ export function countBgProcesses(): BgProcessCounts | null {
37
+ try {
38
+ const tasks = getSharedTaskRegistry()?.allTasks();
39
+ if (!tasks) return null;
40
+ const counts: BgProcessCounts = { running: 0, stopped: 0, failed: 0, done: 0 };
41
+ for (const task of tasks) {
42
+ if (task.status === "running") counts.running++;
43
+ else if (task.status === "killed") counts.stopped++;
44
+ else if (task.status === "failed") counts.failed++;
45
+ else if (task.status === "completed") counts.done++;
46
+ }
47
+ return counts;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Render the centered one-liner for the given terminal width.
55
+ * Returns [] when there is nothing to show.
56
+ */
57
+ export function renderProcessLine(width: number): string[] {
58
+ if (width <= 0) return [];
59
+ const counts = countBgProcesses();
60
+ if (!counts) return [];
61
+
62
+ const parts: string[] = [];
63
+ if (counts.running > 0) parts.push(`${GREEN_DOT} ${counts.running} running`);
64
+ if (counts.stopped > 0) parts.push(`${YELLOW_DOT} ${counts.stopped} stopped`);
65
+ if (counts.failed > 0) parts.push(`${RED_DOT} ${counts.failed} failed`);
66
+ if (counts.done > 0) parts.push(`${GRAY_DOT} ${counts.done} done`);
67
+ if (parts.length === 0) return [];
68
+
69
+ const line = parts.join(" ");
70
+ const w = visibleWidth(line);
71
+ if (w >= width) return [truncateToWidth(line, width)];
72
+ const leftPad = Math.floor((width - w) / 2);
73
+ return [" ".repeat(leftPad) + line];
74
+ }
@@ -56,8 +56,16 @@ interface MessageTpsRecord {
56
56
  requestAt: number;
57
57
  /** When OUTPUT GENERATION started (ms). First non-empty delta. */
58
58
  startedAt: number;
59
+ /** True once a live streaming delta was observed (window is hook-measured). */
60
+ sawFirstDelta: boolean;
59
61
  /** When generation completed (ms), 0 if still generating. */
60
62
  completedAt: number;
63
+ /**
64
+ * MEASURED output-only decode window (first delta → stream end, ms).
65
+ * 0 when never measured — such records are excluded from the session
66
+ * average (deepseek-harness rule: unmeasurable steps drop out).
67
+ */
68
+ decodeMs: number;
61
69
  /** Final TPS for this message (once completed). */
62
70
  tps: number;
63
71
  }
@@ -141,7 +149,10 @@ function estimateOutputTokens(message: unknown): number {
141
149
  }
142
150
  }
143
151
  }
144
- return estimateTokens(String(chars)); // ceil(chars/4) via single call
152
+ // FIXED: was estimateTokens(String(chars)) that divided the DIGIT COUNT
153
+ // of the number (String(8000).length === 4) by 4, collapsing an 8000-char
154
+ // response into 1 "token". Divide the actual char count instead.
155
+ return Math.ceil(chars / CHARS_PER_TOKEN);
145
156
  }
146
157
 
147
158
  /**
@@ -202,7 +213,9 @@ export class TpsTracker {
202
213
  anchored: false,
203
214
  requestAt: this.lastTurnStart,
204
215
  startedAt: 0,
216
+ sawFirstDelta: false,
205
217
  completedAt: 0,
218
+ decodeMs: 0,
206
219
  tps: 0,
207
220
  });
208
221
  }
@@ -215,7 +228,9 @@ export class TpsTracker {
215
228
  // record inherits the current open-turn start as its TTFT bound.
216
229
  requestAt: this.lastTurnStart,
217
230
  startedAt: 0,
231
+ sawFirstDelta: false,
218
232
  completedAt: 0,
233
+ decodeMs: 0,
219
234
  tps: 0,
220
235
  });
221
236
  }
@@ -252,6 +267,7 @@ export class TpsTracker {
252
267
  this.ttftHookSamples += 1;
253
268
  }
254
269
  }
270
+ record.sawFirstDelta = true; // window becomes hook-measured
255
271
  record.estimatedTokens += deltaContribution(deltaText);
256
272
  record.tokens = record.estimatedTokens;
257
273
  }
@@ -279,10 +295,19 @@ export class TpsTracker {
279
295
  // provider message timestamp so the window is still output-ish.
280
296
  record.startedAt =
281
297
  messageTimestamp(finalMessage) || record.completedAt - 500;
282
- } const durationSec = Math.max(
298
+ }
299
+ const durationSec = Math.max(
283
300
  (record.completedAt - record.startedAt) / 1000,
284
301
  0.05,
285
302
  );
303
+ // decodeMs is the measured output-only window ONLY when live hooks saw
304
+ // both bounds; scan-reconstructed windows stay 0 → excluded from AVG.
305
+ if (record.sawFirstDelta && record.startedAt > 0) {
306
+ const ms = Math.max(1, record.completedAt - record.startedAt);
307
+ // Guard: a hook-measured window should never be absurdly long —
308
+ // keep an over-flow safety cap far above any real generation.
309
+ record.decodeMs = Math.min(ms, 3600_000);
310
+ }
286
311
  record.tps = record.tokens > 0 ? record.tokens / durationSec : 0;
287
312
  this.totalOutput += record.tokens;
288
313
  this.pendingChars.delete(messageIndex);
@@ -392,7 +417,9 @@ export class TpsTracker {
392
417
  anchored: false,
393
418
  requestAt: this.lastTurnStart,
394
419
  startedAt: 0,
420
+ sawFirstDelta: false,
395
421
  completedAt: 0,
422
+ decodeMs: 0,
396
423
  tps: 0,
397
424
  });
398
425
  }
@@ -436,49 +463,33 @@ export class TpsTracker {
436
463
  return 0;
437
464
  }
438
465
 
439
- /** Session average TPS across completed + current generation windows. */
440
- /**
441
- * Honest per-record duration override from branch order: a persisted
442
- * assistant message ended by the time the NEXT entry arrived. Feeding
443
- * these durations replaces first-scan-sighting reconstruction (which made
444
- * every historical message look 10 min long → tok/s ≈ 0).
445
- */
446
- syncRecordDurations(durations: Map<number, number>): void {
447
- for (const [idx, durMs] of durations) {
448
- const r = this.records[idx];
449
- if (!r || r.completedAt === 0 || !r.startedAt) continue;
450
- const sec = Math.max(0.05, Math.min(durMs / 1000, TpsTracker.MAX_RECORD_DURATION_SEC));
451
- // Recompute this record's tps contribution lazily via tokens/duration
452
- r.tps = sec > 0 ? r.tokens / sec : r.tps;
453
- (r as unknown as { forcedDurationSec?: number }).forcedDurationSec = sec;
454
- }
455
- }
456
-
457
466
  /** Session average TPS across completed + current generation windows.
458
467
  *
459
- * Scan-reconciled OLD messages (after restart/reload) can yield absurd
460
- * durations: startedAt comes from the provider timestamp, completedAt
461
- * from 'first time our scanner saw it' = now i.e. minutes/hours for a
462
- * message that generated in seconds. Each record's duration is therefore
463
- * clamped to MAX_RECORD_DURATION_SEC before averaging; syncRecordDurations
464
- * supersedes the clamp with real next-entry deltas when available.
468
+ * Deepseek-harness contract (projection.ts): throughput =
469
+ * Σ decodeTokens ÷ Σ decodeMs, sampled ONLY over steps whose decode
470
+ * window was actually measured (first delta stream end by live hooks).
471
+ * Steps without a measured window drop out of the average entirely.
472
+ *
473
+ * Scan-reconciled OLD messages (after restart/reload) previously entered
474
+ * this average with fabricated durations: startedAt from the provider
475
+ * timestamp, completedAt from 'when our scanner first saw it' (minutes
476
+ * late), or worse a 'next-entry-ts' window that silently includes tool
477
+ * runs and user think-time. That made a 100 tok/s model read ~7 tok/s.
478
+ * We now honor the same rule as the harness: unmeasurable steps are
479
+ * EXCLUDED, never averaged in with invented durations.
465
480
  */
466
- private static readonly MAX_RECORD_DURATION_SEC = 600;
467
-
468
481
  getSessionAvgTps(): number {
469
- const cap = TpsTracker.MAX_RECORD_DURATION_SEC;
470
482
  let totalTokens = 0;
471
483
  let totalDurationSec = 0;
472
484
  for (const r of this.records) {
473
- if (r.completedAt > 0 && r.startedAt > 0) {
485
+ if (r.completedAt > 0 && r.startedAt > 0 && r.decodeMs > 0) {
486
+ // Measured output-only window (live streaming hooks).
474
487
  totalTokens += r.tokens;
475
- // Branch-derived duration wins; fallback reconstructs from
476
- // startedAt→completedAt clamped at the cap.
477
- const forced = (r as unknown as { forcedDurationSec?: number }).forcedDurationSec;
478
- totalDurationSec += forced ?? Math.min((r.completedAt - r.startedAt) / 1000, cap);
488
+ totalDurationSec += r.decodeMs / 1000;
479
489
  } else if (r.completedAt === 0 && r.startedAt > 0) {
490
+ // Open generation window: contribute what's elapsed so far.
480
491
  totalTokens += r.tokens;
481
- totalDurationSec += Math.min((Date.now() - r.startedAt) / 1000, cap);
492
+ totalDurationSec += Math.max(0.05, (Date.now() - r.startedAt) / 1000);
482
493
  }
483
494
  }
484
495
  if (totalDurationSec <= 0) return 0;