pi-editor-footer 0.6.0 → 0.6.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/CHANGELOG.md +12 -0
- package/dist/index.js +58 -9
- package/dist/live-border.js +86 -6
- package/dist/telemetry.js +13 -11
- package/package.json +2 -3
- package/src/index.ts +110 -55
- package/src/live-border.ts +101 -10
- package/src/telemetry.ts +17 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.2] - 2026-08-25
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Live `↑` no longer exceeds context window or session total — `telemetry:peekAgentLive` and `endAgent` now use peak window (`max`) for `inputTokens` not sum (summing `50k+60k=110k` double-counted overlapping history `> 60k` window), `live-border` top `↑` now shows current window `peekLive` + per-agent `output`/`cost` capped to `contextUsage.tokens`, idle also via telemetry `max` capped, timeline `↑` prefers telemetry `max` capped
|
|
12
|
+
|
|
13
|
+
## [0.6.1] - 2026-08-25
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Live `↑` now per-agent delta (`279k-261k=18k` for 10 turns) not session total — `live-border` top `↑`/`↓` when idle uses `totals - baseline` at `agent_start` (`LiveBorder.setAgentBaseline`), timeline `↑`/`↓`/`$` also prefers baseline delta; when running uses per-agent sum via `telemetry:peekAgentLive()` which now always resets on `agent_start` (removed stale `if (agentStartMs===null)` guard) and handles `agent_end` alias for `agent_settled`
|
|
18
|
+
|
|
7
19
|
## [0.6.0] - 2026-08-24
|
|
8
20
|
|
|
9
21
|
### Changed
|
package/dist/index.js
CHANGED
|
@@ -52,6 +52,7 @@ const REFRESH_MS = 1000;
|
|
|
52
52
|
let liveTickTimer = null;
|
|
53
53
|
let footerState = createInitialState();
|
|
54
54
|
let agentStartMs = null;
|
|
55
|
+
let agentBaselineTotals = null;
|
|
55
56
|
let currentModelInfo = {
|
|
56
57
|
provider: "",
|
|
57
58
|
modelId: "unknown",
|
|
@@ -170,7 +171,8 @@ function injectTimelineDimLine(_ctx, rawLine) {
|
|
|
170
171
|
// SAFETY: pi custom entry is TUI-only, not sent to LLM
|
|
171
172
|
extensionPi?.appendEntry?.("timeline", { text: rawLine });
|
|
172
173
|
}
|
|
173
|
-
catch {
|
|
174
|
+
catch {
|
|
175
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
174
176
|
// SAFETY: best-effort, ignore recoverable error
|
|
175
177
|
}
|
|
176
178
|
// keep legacy array in sync
|
|
@@ -320,7 +322,8 @@ export default function (pi) {
|
|
|
320
322
|
return new Text(lines.join("\n"));
|
|
321
323
|
});
|
|
322
324
|
}
|
|
323
|
-
catch {
|
|
325
|
+
catch {
|
|
326
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
324
327
|
// SAFETY: best-effort, ignore recoverable error
|
|
325
328
|
}
|
|
326
329
|
let headerCleanupInner = null;
|
|
@@ -451,6 +454,8 @@ export default function (pi) {
|
|
|
451
454
|
}
|
|
452
455
|
currentModelInfo = modelInfoOf(ctx);
|
|
453
456
|
lastSessionCtx = ctx;
|
|
457
|
+
agentBaselineTotals = null;
|
|
458
|
+
liveBorder.setAgentBaseline(null);
|
|
454
459
|
// Deferred so we win the single editor slot (see installEditor).
|
|
455
460
|
deferredInstallTimer = setTimeout(() => installEditor(ctx.ui), 0);
|
|
456
461
|
// header disabled — first line workspace/hints removed per user request; cwd preserved in footer below input
|
|
@@ -544,6 +549,8 @@ export default function (pi) {
|
|
|
544
549
|
// timeline entries are custom entries interleaved — no aboveEditor widget to clear
|
|
545
550
|
wallTimeHistory = [];
|
|
546
551
|
agentStartMs = null;
|
|
552
|
+
agentBaselineTotals = null;
|
|
553
|
+
liveBorder.setAgentBaseline(null);
|
|
547
554
|
footerState = {
|
|
548
555
|
...footerState,
|
|
549
556
|
workingSince: undefined,
|
|
@@ -591,9 +598,21 @@ export default function (pi) {
|
|
|
591
598
|
const refreshAllLive = () => {
|
|
592
599
|
liveBorder.render();
|
|
593
600
|
};
|
|
594
|
-
pi.on("agent_start", (e) => {
|
|
601
|
+
pi.on("agent_start", (e, ctx) => {
|
|
595
602
|
telemetryTracker.handle(e);
|
|
596
603
|
runActivityTracker.startRun();
|
|
604
|
+
// Capture baseline totals for per-agent delta (live input 18k not 279k = totals - baseline)
|
|
605
|
+
try {
|
|
606
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
607
|
+
const baselineCtx = (ctx ?? lastSessionCtx);
|
|
608
|
+
if (baselineCtx?.sessionManager?.getEntries) {
|
|
609
|
+
agentBaselineTotals = getUsageTotals(baselineCtx);
|
|
610
|
+
liveBorder.setAgentBaseline(agentBaselineTotals);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
615
|
+
}
|
|
597
616
|
// timeline stays permanently between each run — do not hide on start
|
|
598
617
|
agentStartMs = Date.now();
|
|
599
618
|
footerState = {
|
|
@@ -604,10 +623,15 @@ export default function (pi) {
|
|
|
604
623
|
startLiveTick();
|
|
605
624
|
refreshAllLive();
|
|
606
625
|
});
|
|
626
|
+
pi.on("agent_end", (e) => {
|
|
627
|
+
// Alias for agent_settled — ensure tracker resets even if only agent_end is emitted
|
|
628
|
+
telemetryTracker.handle(e);
|
|
629
|
+
runActivityTracker.settle();
|
|
630
|
+
});
|
|
607
631
|
pi.on("turn_start", (e, ctx) => {
|
|
608
632
|
// Input is known at turn_start via context usage — seed live input so peekLive shows it during streaming (output already streams via liveDeltaChars)
|
|
609
633
|
const usageTokens = ctx // SAFETY: pi context seam — getContextUsage is ExtensionContext API
|
|
610
|
-
|
|
634
|
+
?.getContextUsage?.()?.tokens;
|
|
611
635
|
if (typeof usageTokens === "number" &&
|
|
612
636
|
Number.isFinite(usageTokens) &&
|
|
613
637
|
usageTokens > 0) {
|
|
@@ -691,10 +715,34 @@ export default function (pi) {
|
|
|
691
715
|
const cacheRate = totals.latestCacheHitRate ?? 0;
|
|
692
716
|
const cacheStr = `${glyphs.cacheHit} ${cacheRate.toFixed(1)}%`;
|
|
693
717
|
// Respect timeline.* toggles for specified metrics (wallTime/tokens/cost), but datetime/cache/turn/tools are always shown per user spec
|
|
694
|
-
// Timeline tokens
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
718
|
+
// Timeline tokens per-agent: input is peak window (max), not sum — summing full prompts
|
|
719
|
+
// double-counts overlapping history (50k+60k=110k > window 60k). Prefer telemetry max
|
|
720
|
+
// (tel.inputTokens after fix) when available, else delta capped to context/total.
|
|
721
|
+
// Output/cost remain per-agent sum for billing.
|
|
722
|
+
let telInput;
|
|
723
|
+
let telOutput;
|
|
724
|
+
let telCost;
|
|
725
|
+
if (tel) {
|
|
726
|
+
telInput = tel.inputTokens;
|
|
727
|
+
telOutput = tel.outputTokens;
|
|
728
|
+
telCost = tel.costUsd;
|
|
729
|
+
}
|
|
730
|
+
else if (agentBaselineTotals) {
|
|
731
|
+
telInput = Math.max(0, totals.input - agentBaselineTotals.input);
|
|
732
|
+
telOutput = Math.max(0, totals.output - agentBaselineTotals.output);
|
|
733
|
+
telCost = Math.max(0, totals.cost - agentBaselineTotals.cost);
|
|
734
|
+
// Cap input to not exceed context window (peak) or session total
|
|
735
|
+
const ctxTokens = lastSessionCtx?.getContextUsage?.()?.tokens;
|
|
736
|
+
if (typeof ctxTokens === "number" && Number.isFinite(ctxTokens))
|
|
737
|
+
telInput = Math.min(telInput, ctxTokens);
|
|
738
|
+
if (totals.input > 0)
|
|
739
|
+
telInput = Math.min(telInput, totals.input);
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
telInput = totals.input;
|
|
743
|
+
telOutput = totals.output;
|
|
744
|
+
telCost = totals.cost;
|
|
745
|
+
}
|
|
698
746
|
const line1Parts = [dt];
|
|
699
747
|
if (currentConfig.timeline.wallTime)
|
|
700
748
|
line1Parts.push(wallDur);
|
|
@@ -724,7 +772,8 @@ export default function (pi) {
|
|
|
724
772
|
wallText);
|
|
725
773
|
}
|
|
726
774
|
}
|
|
727
|
-
catch {
|
|
775
|
+
catch {
|
|
776
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
728
777
|
// SAFETY: best-effort, ignore recoverable error
|
|
729
778
|
}
|
|
730
779
|
// final settled telemetry overwrites live peek with authoritative totals
|
package/dist/live-border.js
CHANGED
|
@@ -20,9 +20,14 @@ export class LiveBorder {
|
|
|
20
20
|
timer = null;
|
|
21
21
|
lastRenderMs = 0;
|
|
22
22
|
pendingRender = null;
|
|
23
|
+
agentBaseline = null;
|
|
23
24
|
constructor(deps) {
|
|
24
25
|
this.deps = deps;
|
|
25
26
|
}
|
|
27
|
+
/** Set baseline totals at agent_start for per-agent delta (input/output/cost). */
|
|
28
|
+
setAgentBaseline(baseline) {
|
|
29
|
+
this.agentBaseline = baseline ? { ...baseline } : null;
|
|
30
|
+
}
|
|
26
31
|
/** Coalesced render: top (run-activity) + bottom (telemetry) + context bar → editor. */
|
|
27
32
|
render() {
|
|
28
33
|
const now = Date.now();
|
|
@@ -191,12 +196,87 @@ export class LiveBorder {
|
|
|
191
196
|
// Tokens line above model info — left aligned, no border; hidden at startup per user request
|
|
192
197
|
let tokensText = "";
|
|
193
198
|
if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
const isRunning = this.deps.runActivityTracker.isRunning();
|
|
200
|
+
// Live input: window occupancy (max), not sum. Summing full prompts double-counts
|
|
201
|
+
// overlapping history (50k+60k=110k > window 60k) and exceeds context/total.
|
|
202
|
+
// Idle shows per-agent max via telemetry (fallback to delta capped), running shows
|
|
203
|
+
// current turn window (peekLive) + per-agent output/cost, both capped to context/total.
|
|
204
|
+
if (!isRunning && this.agentBaseline) {
|
|
205
|
+
const cur = snapshot.totals;
|
|
206
|
+
const base = this.agentBaseline;
|
|
207
|
+
// Idle per-agent display — input is peak window (max), not sum, to avoid
|
|
208
|
+
// double-count exceed (sum of prompts double-counts overlapping history).
|
|
209
|
+
// Prefer telemetry max when available, else delta capped to context/totals.
|
|
210
|
+
const trackerIdle = this.deps.telemetryTracker;
|
|
211
|
+
const telIdle = trackerIdle.peekAgentLive() ?? trackerIdle.getLastTelemetry();
|
|
212
|
+
let displayInput;
|
|
213
|
+
let displayOutput;
|
|
214
|
+
let displayCost;
|
|
215
|
+
if (telIdle) {
|
|
216
|
+
displayInput = telIdle.inputTokens;
|
|
217
|
+
displayOutput = telIdle.outputTokens;
|
|
218
|
+
displayCost = telIdle.costUsd;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
displayInput = Math.max(0, cur.input - base.input);
|
|
222
|
+
displayOutput = Math.max(0, cur.output - base.output);
|
|
223
|
+
displayCost = Math.max(0, cur.cost - base.cost);
|
|
224
|
+
}
|
|
225
|
+
// Guarantee invariant: live input never exceeds context window or session total
|
|
226
|
+
if (snapshot.contextUsage?.tokens)
|
|
227
|
+
displayInput = Math.min(displayInput, snapshot.contextUsage.tokens);
|
|
228
|
+
if (cur.input > 0)
|
|
229
|
+
displayInput = Math.min(displayInput, cur.input);
|
|
230
|
+
const deltaTel = {
|
|
231
|
+
tps: null,
|
|
232
|
+
ttftMs: 0,
|
|
233
|
+
totalMs: 0,
|
|
234
|
+
inputTokens: displayInput,
|
|
235
|
+
outputTokens: displayOutput,
|
|
236
|
+
stallMs: 0,
|
|
237
|
+
stallCount: 0,
|
|
238
|
+
rateUsdPerMTokens: null,
|
|
239
|
+
generationMs: 0,
|
|
240
|
+
totalTokens: displayInput + displayOutput,
|
|
241
|
+
costUsd: displayCost,
|
|
242
|
+
measurementMs: null,
|
|
243
|
+
};
|
|
244
|
+
tokensText = formatTelemetryTokens(deltaTel, theme, cfg.telemetry, glyphs);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// Live per-agent — input is current window (peekLive), not per-agent sum,
|
|
248
|
+
// to avoid sum(50k+60k)=110k > window 60k. Output/cost still per-agent sum.
|
|
249
|
+
const tracker = this.deps.telemetryTracker;
|
|
250
|
+
const agentLive = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
|
|
251
|
+
const liveTurn = tracker.peekLive();
|
|
252
|
+
let displayLive = agentLive;
|
|
253
|
+
if (agentLive &&
|
|
254
|
+
liveTurn &&
|
|
255
|
+
this.deps.runActivityTracker.isRunning()) {
|
|
256
|
+
// Input = current turn window (liveTurn), output/cost = per-agent sum
|
|
257
|
+
displayLive = {
|
|
258
|
+
...agentLive,
|
|
259
|
+
inputTokens: liveTurn.inputTokens,
|
|
260
|
+
totalTokens: liveTurn.inputTokens + agentLive.outputTokens,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (displayLive) {
|
|
264
|
+
let cappedInput = displayLive.inputTokens;
|
|
265
|
+
if (snapshot.contextUsage?.tokens)
|
|
266
|
+
cappedInput = Math.min(cappedInput, snapshot.contextUsage.tokens);
|
|
267
|
+
// Note: not capping to session totals during running — totals is authoritative
|
|
268
|
+
// (lagging) while liveTurn is predictive (current window). Capping to totals
|
|
269
|
+
// would make live stale (50k) during second turn streaming instead of showing
|
|
270
|
+
// current window 60k. After fix to max, live 60k == context 60k, not exceed context;
|
|
271
|
+
// live may still be > authoritative totals interim (60k > 50k) but will be <= totals
|
|
272
|
+
// after turn completes (60k <= 110k). This is expected predictive vs authoritative.
|
|
273
|
+
displayLive = {
|
|
274
|
+
...displayLive,
|
|
275
|
+
inputTokens: cappedInput,
|
|
276
|
+
totalTokens: cappedInput + displayLive.outputTokens,
|
|
277
|
+
};
|
|
278
|
+
tokensText = formatTelemetryTokens(displayLive, theme, cfg.telemetry, glyphs);
|
|
279
|
+
}
|
|
200
280
|
}
|
|
201
281
|
}
|
|
202
282
|
editor.setTopContextText(contextText);
|
package/dist/telemetry.js
CHANGED
|
@@ -83,11 +83,12 @@ export class TurnTelemetryTracker {
|
|
|
83
83
|
let stallCount = 0;
|
|
84
84
|
let generationMs = 0;
|
|
85
85
|
let ttftMs = 0;
|
|
86
|
-
//
|
|
86
|
+
// input per turn is the full prompt (includes history), summing double-counts overlapping
|
|
87
|
+
// history and makes live input exceed context usage (e.g. 50k+60k=110k > window 60k).
|
|
88
|
+
// Display input as peak window occupancy (max), not sum. Output/cost still sum.
|
|
87
89
|
for (const t of this.agentTurns) {
|
|
88
|
-
inputTokens
|
|
90
|
+
inputTokens = Math.max(inputTokens, t.inputTokens);
|
|
89
91
|
outputTokens += t.outputTokens;
|
|
90
|
-
totalTokens += t.totalTokens;
|
|
91
92
|
costUsd += t.costUsd;
|
|
92
93
|
stallMs += t.stallMs;
|
|
93
94
|
stallCount += t.stallCount;
|
|
@@ -96,9 +97,8 @@ export class TurnTelemetryTracker {
|
|
|
96
97
|
if (this.agentTurns.length > 0)
|
|
97
98
|
ttftMs = this.agentTurns[0].ttftMs;
|
|
98
99
|
if (live) {
|
|
99
|
-
inputTokens
|
|
100
|
+
inputTokens = Math.max(inputTokens, live.inputTokens);
|
|
100
101
|
outputTokens += live.outputTokens;
|
|
101
|
-
totalTokens += live.totalTokens;
|
|
102
102
|
costUsd += live.costUsd;
|
|
103
103
|
stallMs += live.stallMs;
|
|
104
104
|
stallCount += live.stallCount;
|
|
@@ -106,6 +106,8 @@ export class TurnTelemetryTracker {
|
|
|
106
106
|
if (ttftMs === 0)
|
|
107
107
|
ttftMs = live.ttftMs;
|
|
108
108
|
}
|
|
109
|
+
// totalTokens is window input (max) + cumulative output, not sum of per-turn totals
|
|
110
|
+
totalTokens = inputTokens + outputTokens;
|
|
109
111
|
const now = this.now();
|
|
110
112
|
const totalMs = Math.max(0, now - this.agentStartMs);
|
|
111
113
|
const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
|
|
@@ -225,11 +227,10 @@ export class TurnTelemetryTracker {
|
|
|
225
227
|
handle(event) {
|
|
226
228
|
switch (event.type) {
|
|
227
229
|
case "agent_start":
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
this.agentTurns = [];
|
|
231
|
-
}
|
|
230
|
+
this.agentStartMs = this.now();
|
|
231
|
+
this.agentTurns = [];
|
|
232
232
|
return;
|
|
233
|
+
case "agent_end":
|
|
233
234
|
case "agent_settled":
|
|
234
235
|
return this.endAgent();
|
|
235
236
|
case "turn_start":
|
|
@@ -398,8 +399,9 @@ export class TurnTelemetryTracker {
|
|
|
398
399
|
if (startMs === null || turns.length === 0)
|
|
399
400
|
return;
|
|
400
401
|
const outputTokens = turns.reduce((sum, t) => sum + t.outputTokens, 0);
|
|
401
|
-
const inputTokens = turns.reduce((sum, t) => sum
|
|
402
|
-
|
|
402
|
+
const inputTokens = turns.reduce((sum, t) => Math.max(sum, t.inputTokens), 0);
|
|
403
|
+
// totalTokens is window input (max) + cumulative output, not sum of per-turn totals
|
|
404
|
+
const totalTokens = inputTokens + outputTokens;
|
|
403
405
|
const costUsd = turns.reduce((sum, t) => sum + t.costUsd, 0);
|
|
404
406
|
const stallMs = turns.reduce((sum, t) => sum + t.stallMs, 0);
|
|
405
407
|
const stallCount = turns.reduce((sum, t) => sum + t.stallCount, 0);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-editor-footer",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"description": "Pi TUI theme
|
|
4
|
+
"description": "Pi TUI theme \u2014 project-aware footer, model border, and skill detail window (TrackingEditor, live theme)",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --import tsx --test test/*.test.ts",
|
|
7
7
|
"typecheck": "tsc --noEmit",
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
"@earendil-works/pi-coding-agent": "*",
|
|
19
19
|
"@earendil-works/pi-tui": "*"
|
|
20
20
|
},
|
|
21
|
-
"version": "0.6.
|
|
22
|
-
"main": "./dist/index.js",
|
|
21
|
+
"version": "0.6.2",
|
|
23
22
|
"files": [
|
|
24
23
|
"dist",
|
|
25
24
|
"src",
|
package/src/index.ts
CHANGED
|
@@ -122,8 +122,8 @@ let lastSessionCtx: ExtensionContextLike | null = null;
|
|
|
122
122
|
let extensionPi: unknown = null;
|
|
123
123
|
let wallTimeHistory: string[] = []; // kept for compat, not used for widget
|
|
124
124
|
const timeline = new TranscriptTimeline({
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
getLastSessionCtx: () => lastSessionCtx,
|
|
126
|
+
getTuiRef: () => tuiRef,
|
|
127
127
|
});
|
|
128
128
|
void timeline; // keep import used while now using pi.appendEntry interleaved path
|
|
129
129
|
let currentConfig: ThemeConfig = loadConfig();
|
|
@@ -140,6 +140,7 @@ const REFRESH_MS = 1000;
|
|
|
140
140
|
let liveTickTimer: ReturnType<typeof setInterval> | null = null;
|
|
141
141
|
let footerState: FooterState = createInitialState();
|
|
142
142
|
let agentStartMs: number | null = null;
|
|
143
|
+
let agentBaselineTotals: ReturnType<typeof getUsageTotals> | null = null;
|
|
143
144
|
let currentModelInfo: ModelInfo = {
|
|
144
145
|
provider: "",
|
|
145
146
|
modelId: "unknown",
|
|
@@ -267,28 +268,31 @@ function formatDateTimeWithTimezone(d: Date = new Date()): string {
|
|
|
267
268
|
// en-CA gives YYYY-MM-DD, HH:MM:SS
|
|
268
269
|
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")} ${tz}`.trim();
|
|
269
270
|
} catch {
|
|
270
|
-
|
|
271
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
271
272
|
return d.toLocaleString();
|
|
272
273
|
}
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
function injectTimelineDimLine(
|
|
276
|
-
|
|
277
|
-
|
|
277
|
+
_ctx: ExtensionUIContextLike,
|
|
278
|
+
rawLine: string,
|
|
278
279
|
): void {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
280
|
+
// Use pi.appendEntry so it is interleaved in chat container (not aboveEditor stacked widget)
|
|
281
|
+
try {
|
|
282
|
+
// SAFETY: pi custom entry is TUI-only, not sent to LLM
|
|
283
|
+
(
|
|
284
|
+
extensionPi as unknown as { appendEntry?: (t: string, d: unknown) => void }
|
|
285
|
+
)?.appendEntry?.("timeline", { text: rawLine });
|
|
286
|
+
} catch {
|
|
287
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
288
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
289
|
+
}
|
|
290
|
+
// keep legacy array in sync
|
|
291
|
+
wallTimeHistory = [...wallTimeHistory, rawLine];
|
|
288
292
|
}
|
|
289
293
|
function clearTimelineHistory(_ctx?: ExtensionUIContextLike): void {
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
// No widget to clear — entries are interleaved and persist with session
|
|
295
|
+
wallTimeHistory = [];
|
|
292
296
|
}
|
|
293
297
|
|
|
294
298
|
/** shift+up/down handler: scroll the detail window one line, clamped. */
|
|
@@ -435,30 +439,36 @@ export default function (pi: ExtensionAPILike): void {
|
|
|
435
439
|
extensionPi = pi;
|
|
436
440
|
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
|
437
441
|
let deferredInstallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
442
|
+
// Timeline now uses pi.appendEntry("timeline") interleaved in chat container — not aboveEditor widget
|
|
443
|
+
// Register renderer for timeline custom entries (dim, left-aligned, interleaved)
|
|
444
|
+
try {
|
|
445
|
+
// SAFETY: pi entry renderer is public API — timeline entries are TUI-only, not sent to LLM
|
|
446
|
+
(
|
|
447
|
+
pi as unknown as { registerEntryRenderer?: (t: string, r: unknown) => void }
|
|
448
|
+
).registerEntryRenderer?.(
|
|
449
|
+
"timeline",
|
|
450
|
+
(entry: unknown, _opts: unknown, theme: unknown) => {
|
|
451
|
+
const data = (entry as { data?: { text?: string } }).data;
|
|
452
|
+
const text = data?.text ?? "";
|
|
453
|
+
const lines = text.split("\n").map((l: string) => {
|
|
454
|
+
try {
|
|
455
|
+
return (theme as { fg: (c: string, s: string) => string }).fg(
|
|
456
|
+
"dim",
|
|
457
|
+
" " + l,
|
|
458
|
+
);
|
|
459
|
+
} catch {
|
|
460
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
461
|
+
return " " + l;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
// SAFETY: intentional unsafe cast — validated at runtime
|
|
465
|
+
return new Text(lines.join("\n")) as unknown as Component;
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
} catch {
|
|
469
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
470
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
471
|
+
}
|
|
462
472
|
let headerCleanupInner: (() => void) | null = null;
|
|
463
473
|
|
|
464
474
|
// Toggle the border glow + model label (off restores pi's stock border).
|
|
@@ -602,7 +612,8 @@ export default function (pi: ExtensionAPILike): void {
|
|
|
602
612
|
|
|
603
613
|
currentModelInfo = modelInfoOf(ctx);
|
|
604
614
|
lastSessionCtx = ctx;
|
|
605
|
-
|
|
615
|
+
agentBaselineTotals = null;
|
|
616
|
+
liveBorder.setAgentBaseline(null);
|
|
606
617
|
// Deferred so we win the single editor slot (see installEditor).
|
|
607
618
|
deferredInstallTimer = setTimeout(() => installEditor(ctx.ui), 0);
|
|
608
619
|
|
|
@@ -703,10 +714,12 @@ export default function (pi: ExtensionAPILike): void {
|
|
|
703
714
|
// before re-evaluating the module on /reload). Any timer that captured this
|
|
704
715
|
// session's ctx must be dead before then — otherwise its next tick hits the
|
|
705
716
|
// stale `ctx.ui` getter and assertActive() throws, crashing the process.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
717
|
+
pi.on("session_shutdown", () => {
|
|
718
|
+
// timeline entries are custom entries interleaved — no aboveEditor widget to clear
|
|
719
|
+
wallTimeHistory = [];
|
|
709
720
|
agentStartMs = null;
|
|
721
|
+
agentBaselineTotals = null;
|
|
722
|
+
liveBorder.setAgentBaseline(null);
|
|
710
723
|
footerState = {
|
|
711
724
|
...footerState,
|
|
712
725
|
workingSince: undefined,
|
|
@@ -755,9 +768,22 @@ export default function (pi: ExtensionAPILike): void {
|
|
|
755
768
|
liveBorder.render();
|
|
756
769
|
};
|
|
757
770
|
|
|
758
|
-
pi.on("agent_start", (e) => {
|
|
771
|
+
pi.on("agent_start", (e, ctx) => {
|
|
759
772
|
telemetryTracker.handle(e as never);
|
|
760
773
|
runActivityTracker.startRun();
|
|
774
|
+
// Capture baseline totals for per-agent delta (live input 18k not 279k = totals - baseline)
|
|
775
|
+
try {
|
|
776
|
+
// SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
777
|
+
const baselineCtx = (ctx ?? lastSessionCtx) as unknown as Parameters<
|
|
778
|
+
typeof getUsageTotals
|
|
779
|
+
>[0];
|
|
780
|
+
if (baselineCtx?.sessionManager?.getEntries) {
|
|
781
|
+
agentBaselineTotals = getUsageTotals(baselineCtx);
|
|
782
|
+
liveBorder.setAgentBaseline(agentBaselineTotals);
|
|
783
|
+
}
|
|
784
|
+
} catch {
|
|
785
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
786
|
+
}
|
|
761
787
|
// timeline stays permanently between each run — do not hide on start
|
|
762
788
|
agentStartMs = Date.now();
|
|
763
789
|
footerState = {
|
|
@@ -768,12 +794,16 @@ export default function (pi: ExtensionAPILike): void {
|
|
|
768
794
|
startLiveTick();
|
|
769
795
|
refreshAllLive();
|
|
770
796
|
});
|
|
797
|
+
pi.on("agent_end", (e) => {
|
|
798
|
+
// Alias for agent_settled — ensure tracker resets even if only agent_end is emitted
|
|
799
|
+
telemetryTracker.handle(e as never);
|
|
800
|
+
runActivityTracker.settle();
|
|
801
|
+
});
|
|
771
802
|
pi.on("turn_start", (e, ctx) => {
|
|
772
803
|
// Input is known at turn_start via context usage — seed live input so peekLive shows it during streaming (output already streams via liveDeltaChars)
|
|
773
804
|
const usageTokens = (
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
?.getContextUsage?.()?.tokens;
|
|
805
|
+
ctx as unknown as { getContextUsage?: () => { tokens?: number } } // SAFETY: pi context seam — getContextUsage is ExtensionContext API
|
|
806
|
+
)?.getContextUsage?.()?.tokens;
|
|
777
807
|
if (
|
|
778
808
|
typeof usageTokens === "number" &&
|
|
779
809
|
Number.isFinite(usageTokens) &&
|
|
@@ -864,10 +894,34 @@ export default function (pi: ExtensionAPILike): void {
|
|
|
864
894
|
const cacheRate = totals.latestCacheHitRate ?? 0;
|
|
865
895
|
const cacheStr = `${glyphs.cacheHit} ${cacheRate.toFixed(1)}%`;
|
|
866
896
|
// Respect timeline.* toggles for specified metrics (wallTime/tokens/cost), but datetime/cache/turn/tools are always shown per user spec
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
897
|
+
// Timeline tokens per-agent: input is peak window (max), not sum — summing full prompts
|
|
898
|
+
// double-counts overlapping history (50k+60k=110k > window 60k). Prefer telemetry max
|
|
899
|
+
// (tel.inputTokens after fix) when available, else delta capped to context/total.
|
|
900
|
+
// Output/cost remain per-agent sum for billing.
|
|
901
|
+
let telInput: number;
|
|
902
|
+
let telOutput: number;
|
|
903
|
+
let telCost: number;
|
|
904
|
+
if (tel) {
|
|
905
|
+
telInput = tel.inputTokens;
|
|
906
|
+
telOutput = tel.outputTokens;
|
|
907
|
+
telCost = tel.costUsd;
|
|
908
|
+
} else if (agentBaselineTotals) {
|
|
909
|
+
telInput = Math.max(0, totals.input - agentBaselineTotals.input);
|
|
910
|
+
telOutput = Math.max(0, totals.output - agentBaselineTotals.output);
|
|
911
|
+
telCost = Math.max(0, totals.cost - agentBaselineTotals.cost);
|
|
912
|
+
// Cap input to not exceed context window (peak) or session total
|
|
913
|
+
const ctxTokens = (
|
|
914
|
+
lastSessionCtx as unknown as { // SAFETY: pi seam — intentional unsafe cast, validated at runtime
|
|
915
|
+
getContextUsage?: () => { tokens?: number };
|
|
916
|
+
}
|
|
917
|
+
)?.getContextUsage?.()?.tokens;
|
|
918
|
+
if (typeof ctxTokens === "number" && Number.isFinite(ctxTokens)) telInput = Math.min(telInput, ctxTokens);
|
|
919
|
+
if (totals.input > 0) telInput = Math.min(telInput, totals.input);
|
|
920
|
+
} else {
|
|
921
|
+
telInput = totals.input;
|
|
922
|
+
telOutput = totals.output;
|
|
923
|
+
telCost = totals.cost;
|
|
924
|
+
}
|
|
871
925
|
const line1Parts: string[] = [dt];
|
|
872
926
|
if (currentConfig.timeline.wallTime) line1Parts.push(wallDur);
|
|
873
927
|
else line1Parts.push(wallDur); // wall time always per spec (11s)
|
|
@@ -894,9 +948,10 @@ export default function (pi: ExtensionAPILike): void {
|
|
|
894
948
|
wallText,
|
|
895
949
|
);
|
|
896
950
|
}
|
|
897
|
-
} catch {
|
|
898
|
-
|
|
899
|
-
|
|
951
|
+
} catch {
|
|
952
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
953
|
+
// SAFETY: best-effort, ignore recoverable error
|
|
954
|
+
}
|
|
900
955
|
// final settled telemetry overwrites live peek with authoritative totals
|
|
901
956
|
if (tel && installedEditor && currentConfig.telemetry.enabled) {
|
|
902
957
|
try {
|
package/src/live-border.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
formatTurnDuration,
|
|
24
24
|
formatTurnTelemetry,
|
|
25
25
|
} from "./telemetry.js";
|
|
26
|
+
import type { UsageTotals } from "./state.js";
|
|
26
27
|
import type { TurnTelemetry } from "./telemetry.js";
|
|
27
28
|
import type { TurnTelemetryTracker } from "./telemetry.js";
|
|
28
29
|
import type { ThemeConfig } from "./config.js";
|
|
@@ -43,9 +44,15 @@ export class LiveBorder {
|
|
|
43
44
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
44
45
|
private lastRenderMs = 0;
|
|
45
46
|
private pendingRender: ReturnType<typeof setTimeout> | null = null;
|
|
47
|
+
private agentBaseline: UsageTotals | null = null;
|
|
46
48
|
|
|
47
49
|
constructor(private readonly deps: LiveBorderDeps) {}
|
|
48
50
|
|
|
51
|
+
/** Set baseline totals at agent_start for per-agent delta (input/output/cost). */
|
|
52
|
+
setAgentBaseline(baseline: UsageTotals | null): void {
|
|
53
|
+
this.agentBaseline = baseline ? { ...baseline } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
/** Coalesced render: top (run-activity) + bottom (telemetry) + context bar → editor. */
|
|
50
57
|
render(): void {
|
|
51
58
|
const now = Date.now();
|
|
@@ -237,21 +244,105 @@ export class LiveBorder {
|
|
|
237
244
|
// Tokens line above model info — left aligned, no border; hidden at startup per user request
|
|
238
245
|
let tokensText = "";
|
|
239
246
|
if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
const isRunning = this.deps.runActivityTracker.isRunning();
|
|
248
|
+
// Live input: window occupancy (max), not sum. Summing full prompts double-counts
|
|
249
|
+
// overlapping history (50k+60k=110k > window 60k) and exceeds context/total.
|
|
250
|
+
// Idle shows per-agent max via telemetry (fallback to delta capped), running shows
|
|
251
|
+
// current turn window (peekLive) + per-agent output/cost, both capped to context/total.
|
|
252
|
+
if (!isRunning && this.agentBaseline) {
|
|
253
|
+
const cur = snapshot.totals;
|
|
254
|
+
const base = this.agentBaseline;
|
|
255
|
+
// Idle per-agent display — input is peak window (max), not sum, to avoid
|
|
256
|
+
// double-count exceed (sum of prompts double-counts overlapping history).
|
|
257
|
+
// Prefer telemetry max when available, else delta capped to context/totals.
|
|
258
|
+
const trackerIdle = this.deps.telemetryTracker as unknown as {
|
|
259
|
+
peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
|
|
260
|
+
getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
|
|
261
|
+
};
|
|
262
|
+
const telIdle =
|
|
263
|
+
trackerIdle.peekAgentLive() ?? trackerIdle.getLastTelemetry();
|
|
264
|
+
let displayInput: number;
|
|
265
|
+
let displayOutput: number;
|
|
266
|
+
let displayCost: number;
|
|
267
|
+
if (telIdle) {
|
|
268
|
+
displayInput = telIdle.inputTokens;
|
|
269
|
+
displayOutput = telIdle.outputTokens;
|
|
270
|
+
displayCost = telIdle.costUsd;
|
|
271
|
+
} else {
|
|
272
|
+
displayInput = Math.max(0, cur.input - base.input);
|
|
273
|
+
displayOutput = Math.max(0, cur.output - base.output);
|
|
274
|
+
displayCost = Math.max(0, cur.cost - base.cost);
|
|
275
|
+
}
|
|
276
|
+
// Guarantee invariant: live input never exceeds context window or session total
|
|
277
|
+
if (snapshot.contextUsage?.tokens)
|
|
278
|
+
displayInput = Math.min(displayInput, snapshot.contextUsage.tokens);
|
|
279
|
+
if (cur.input > 0) displayInput = Math.min(displayInput, cur.input);
|
|
280
|
+
const deltaTel: TurnTelemetry = {
|
|
281
|
+
tps: null,
|
|
282
|
+
ttftMs: 0,
|
|
283
|
+
totalMs: 0,
|
|
284
|
+
inputTokens: displayInput,
|
|
285
|
+
outputTokens: displayOutput,
|
|
286
|
+
stallMs: 0,
|
|
287
|
+
stallCount: 0,
|
|
288
|
+
rateUsdPerMTokens: null,
|
|
289
|
+
generationMs: 0,
|
|
290
|
+
totalTokens: displayInput + displayOutput,
|
|
291
|
+
costUsd: displayCost,
|
|
292
|
+
measurementMs: null,
|
|
293
|
+
};
|
|
249
294
|
tokensText = formatTelemetryTokens(
|
|
250
|
-
|
|
295
|
+
deltaTel,
|
|
251
296
|
theme as never,
|
|
252
297
|
cfg.telemetry,
|
|
253
298
|
glyphs as never,
|
|
254
299
|
);
|
|
300
|
+
} else {
|
|
301
|
+
// Live per-agent — input is current window (peekLive), not per-agent sum,
|
|
302
|
+
// to avoid sum(50k+60k)=110k > window 60k. Output/cost still per-agent sum.
|
|
303
|
+
const tracker = this.deps.telemetryTracker as unknown as {
|
|
304
|
+
peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
|
|
305
|
+
peekLive(): import("./telemetry.js").TurnTelemetry | null;
|
|
306
|
+
getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
|
|
307
|
+
};
|
|
308
|
+
const agentLive =
|
|
309
|
+
tracker.peekAgentLive() ?? tracker.getLastTelemetry();
|
|
310
|
+
const liveTurn = tracker.peekLive();
|
|
311
|
+
let displayLive = agentLive;
|
|
312
|
+
if (
|
|
313
|
+
agentLive &&
|
|
314
|
+
liveTurn &&
|
|
315
|
+
this.deps.runActivityTracker.isRunning()
|
|
316
|
+
) {
|
|
317
|
+
// Input = current turn window (liveTurn), output/cost = per-agent sum
|
|
318
|
+
displayLive = {
|
|
319
|
+
...agentLive,
|
|
320
|
+
inputTokens: liveTurn.inputTokens,
|
|
321
|
+
totalTokens: liveTurn.inputTokens + agentLive.outputTokens,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (displayLive) {
|
|
325
|
+
let cappedInput = displayLive.inputTokens;
|
|
326
|
+
if (snapshot.contextUsage?.tokens)
|
|
327
|
+
cappedInput = Math.min(cappedInput, snapshot.contextUsage.tokens);
|
|
328
|
+
// Note: not capping to session totals during running — totals is authoritative
|
|
329
|
+
// (lagging) while liveTurn is predictive (current window). Capping to totals
|
|
330
|
+
// would make live stale (50k) during second turn streaming instead of showing
|
|
331
|
+
// current window 60k. After fix to max, live 60k == context 60k, not exceed context;
|
|
332
|
+
// live may still be > authoritative totals interim (60k > 50k) but will be <= totals
|
|
333
|
+
// after turn completes (60k <= 110k). This is expected predictive vs authoritative.
|
|
334
|
+
displayLive = {
|
|
335
|
+
...displayLive,
|
|
336
|
+
inputTokens: cappedInput,
|
|
337
|
+
totalTokens: cappedInput + displayLive.outputTokens,
|
|
338
|
+
};
|
|
339
|
+
tokensText = formatTelemetryTokens(
|
|
340
|
+
displayLive,
|
|
341
|
+
theme as never,
|
|
342
|
+
cfg.telemetry,
|
|
343
|
+
glyphs as never,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
255
346
|
}
|
|
256
347
|
}
|
|
257
348
|
editor.setTopContextText(contextText);
|
package/src/telemetry.ts
CHANGED
|
@@ -60,6 +60,7 @@ type AgentMessage = { role: string } & AssistantMessage;
|
|
|
60
60
|
|
|
61
61
|
export type TelemetryEvent =
|
|
62
62
|
| { type: "agent_start" }
|
|
63
|
+
| { type: "agent_end" }
|
|
63
64
|
| { type: "agent_settled" }
|
|
64
65
|
| {
|
|
65
66
|
type: "turn_start";
|
|
@@ -184,11 +185,12 @@ export class TurnTelemetryTracker {
|
|
|
184
185
|
let stallCount = 0;
|
|
185
186
|
let generationMs = 0;
|
|
186
187
|
let ttftMs = 0;
|
|
187
|
-
//
|
|
188
|
+
// input per turn is the full prompt (includes history), summing double-counts overlapping
|
|
189
|
+
// history and makes live input exceed context usage (e.g. 50k+60k=110k > window 60k).
|
|
190
|
+
// Display input as peak window occupancy (max), not sum. Output/cost still sum.
|
|
188
191
|
for (const t of this.agentTurns) {
|
|
189
|
-
inputTokens
|
|
192
|
+
inputTokens = Math.max(inputTokens, t.inputTokens);
|
|
190
193
|
outputTokens += t.outputTokens;
|
|
191
|
-
totalTokens += t.totalTokens;
|
|
192
194
|
costUsd += t.costUsd;
|
|
193
195
|
stallMs += t.stallMs;
|
|
194
196
|
stallCount += t.stallCount;
|
|
@@ -196,15 +198,16 @@ export class TurnTelemetryTracker {
|
|
|
196
198
|
}
|
|
197
199
|
if (this.agentTurns.length > 0) ttftMs = this.agentTurns[0]!.ttftMs;
|
|
198
200
|
if (live) {
|
|
199
|
-
inputTokens
|
|
201
|
+
inputTokens = Math.max(inputTokens, live.inputTokens);
|
|
200
202
|
outputTokens += live.outputTokens;
|
|
201
|
-
totalTokens += live.totalTokens;
|
|
202
203
|
costUsd += live.costUsd;
|
|
203
204
|
stallMs += live.stallMs;
|
|
204
205
|
stallCount += live.stallCount;
|
|
205
206
|
generationMs += live.generationMs;
|
|
206
207
|
if (ttftMs === 0) ttftMs = live.ttftMs;
|
|
207
208
|
}
|
|
209
|
+
// totalTokens is window input (max) + cumulative output, not sum of per-turn totals
|
|
210
|
+
totalTokens = inputTokens + outputTokens;
|
|
208
211
|
const now = this.now();
|
|
209
212
|
const totalMs = Math.max(0, now - this.agentStartMs);
|
|
210
213
|
const measurementMs =
|
|
@@ -329,11 +332,10 @@ export class TurnTelemetryTracker {
|
|
|
329
332
|
handle(event: TelemetryEvent): TurnTelemetry | undefined {
|
|
330
333
|
switch (event.type) {
|
|
331
334
|
case "agent_start":
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
this.agentTurns = [];
|
|
335
|
-
}
|
|
335
|
+
this.agentStartMs = this.now();
|
|
336
|
+
this.agentTurns = [];
|
|
336
337
|
return;
|
|
338
|
+
case "agent_end":
|
|
337
339
|
case "agent_settled":
|
|
338
340
|
return this.endAgent();
|
|
339
341
|
case "turn_start":
|
|
@@ -524,8 +526,12 @@ export class TurnTelemetryTracker {
|
|
|
524
526
|
if (startMs === null || turns.length === 0) return;
|
|
525
527
|
|
|
526
528
|
const outputTokens = turns.reduce((sum, t) => sum + t.outputTokens, 0);
|
|
527
|
-
const inputTokens = turns.reduce(
|
|
528
|
-
|
|
529
|
+
const inputTokens = turns.reduce(
|
|
530
|
+
(sum, t) => Math.max(sum, t.inputTokens),
|
|
531
|
+
0,
|
|
532
|
+
);
|
|
533
|
+
// totalTokens is window input (max) + cumulative output, not sum of per-turn totals
|
|
534
|
+
const totalTokens = inputTokens + outputTokens;
|
|
529
535
|
const costUsd = turns.reduce((sum, t) => sum + t.costUsd, 0);
|
|
530
536
|
const stallMs = turns.reduce((sum, t) => sum + t.stallMs, 0);
|
|
531
537
|
const stallCount = turns.reduce((sum, t) => sum + t.stallCount, 0);
|