@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 +1 -0
- package/package.json +3 -2
- package/src/index.ts +12 -19
- package/src/process-line.ts +74 -0
- package/src/tps-tracker.ts +46 -35
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.
|
|
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
|
-
|
|
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 —
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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
|
|
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
|
+
}
|
package/src/tps-tracker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
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
|
-
|
|
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.
|
|
492
|
+
totalDurationSec += Math.max(0.05, (Date.now() - r.startedAt) / 1000);
|
|
482
493
|
}
|
|
483
494
|
}
|
|
484
495
|
if (totalDurationSec <= 0) return 0;
|