pi-editor-footer 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.1] - 2026-08-25
8
+
9
+ ### Fixed
10
+
11
+ - 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`
12
+
13
+ ## [0.6.0] - 2026-08-24
14
+
15
+ ### Changed
16
+
17
+ - Context usage colors now `12.5%`/`25%`/`50%` quotas — `dim` 0-12.5 → `accent` 12.5-25 → `warning` 25-50 → `error` 50-100 via `color-policy:contextUsageColor` (was `25%`/`50%`/`75%`), respects theme semantic tokens
18
+ - Live `↑`/`↓` tokens now per agent run (option B) — cumulative across turns in this agent via `telemetry:peekAgentLive()` + `live-border` top `↑`/`↓` (was per-turn via `getLastTurnTelemetry`), `getLastTelemetry` stays agent sum
19
+ - Stall relocated from bottom telemetry to top right of tool use with `dim |` pipe — `run-activity` now `tools | !2×3.3s` top, bottom `telemetry:formatTurnTelemetry` now `TPS · TTFT` only (suppressed `stalls:false`)
20
+
7
21
  ## [0.5.0] - 2026-08-22
8
22
 
9
23
  ### Changed
@@ -6,13 +6,21 @@ export function stressColor(value, warn = 70, danger = 90) {
6
6
  return "accent";
7
7
  }
8
8
  export function contextUsageColor(pct) {
9
- // 25% 50% 75% thresholds — transit from dimmed (low) to highlight (high)
10
- // for intuitive quota status: dim (<25) → accent (25-50) → warning (50-75) → error (≥75)
11
- if (pct >= 75)
12
- return "error";
9
+ // 12.5 / 25 / 50 quotas — four urgency tiers, increasingly aggressive as context fills.
10
+ // 0 – 12.5% dim — plenty of headroom, visually quiet.
11
+ // 12.5 – 25% accent — first nudge, noticeable but calm.
12
+ // 25 – 50% warning — half consumed, needs attention.
13
+ // 50 – 100% error — critical, about to run out.
14
+ // Uses theme semantic tokens (dim/accent/warning/error) so the progression
15
+ // respects the active theme and remains legible on light/dark/custom palettes.
16
+ // A fixed hex palette (e.g. grey→sky→amber→red) would be more vivid but
17
+ // would ignore the user's theme and can clash with light backgrounds —
18
+ // semantic tokens keep the "aggressive" ordering while staying theme-coherent.
13
19
  if (pct >= 50)
14
- return "warning";
20
+ return "error";
15
21
  if (pct >= 25)
22
+ return "warning";
23
+ if (pct >= 12.5)
16
24
  return "accent";
17
25
  return "dim";
18
26
  }
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 { // SAFETY: best-effort, ignore recoverable error
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 { // SAFETY: best-effort, ignore recoverable error
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,6 +623,11 @@ 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
@@ -691,10 +715,26 @@ 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 now mirror telemetry (per-agent) not session totals — input known at turn_start via liveInputTokens
695
- const telInput = tel ? tel.inputTokens : totals.input;
696
- const telOutput = tel ? tel.outputTokens : totals.output;
697
- const telCost = tel ? tel.costUsd : totals.cost;
718
+ // Timeline tokens per-agent: prefer baseline delta (totals - baseline) which yields 18k (279k-261k) not 279k total.
719
+ // Fallback to tel (tracker sum) then session totals.
720
+ let telInput;
721
+ let telOutput;
722
+ let telCost;
723
+ if (agentBaselineTotals) {
724
+ telInput = Math.max(0, totals.input - agentBaselineTotals.input);
725
+ telOutput = Math.max(0, totals.output - agentBaselineTotals.output);
726
+ telCost = Math.max(0, totals.cost - agentBaselineTotals.cost);
727
+ }
728
+ else if (tel) {
729
+ telInput = tel.inputTokens;
730
+ telOutput = tel.outputTokens;
731
+ telCost = tel.costUsd;
732
+ }
733
+ else {
734
+ telInput = totals.input;
735
+ telOutput = totals.output;
736
+ telCost = totals.cost;
737
+ }
698
738
  const line1Parts = [dt];
699
739
  if (currentConfig.timeline.wallTime)
700
740
  line1Parts.push(wallDur);
@@ -724,7 +764,8 @@ export default function (pi) {
724
764
  wallText);
725
765
  }
726
766
  }
727
- catch { // SAFETY: best-effort, ignore recoverable error
767
+ catch {
768
+ // SAFETY: best-effort, ignore recoverable error
728
769
  // SAFETY: best-effort, ignore recoverable error
729
770
  }
730
771
  // final settled telemetry overwrites live peek with authoritative totals
@@ -13,16 +13,21 @@
13
13
  import { createChromeSnapshot, formatTopContextFromSnapshot, } from "./chrome-state.js";
14
14
  import { resolveGlyphs, resolveIconMode } from "./icons.js";
15
15
  import { formatRunActivityTopRight } from "./run-activity.js";
16
- import { formatTelemetryTokens, formatTurnTelemetry } from "./telemetry.js";
16
+ import { formatTelemetryTokens, formatTurnDuration, formatTurnTelemetry, } from "./telemetry.js";
17
17
  export const REFRESH_MS = 1000;
18
18
  export class LiveBorder {
19
19
  deps;
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();
@@ -92,13 +97,33 @@ export class LiveBorder {
92
97
  refreshTopBorder() {
93
98
  const editor = this.deps.getEditor();
94
99
  const ctx = this.deps.getCtx();
100
+ const cfg = this.deps.getConfig();
95
101
  if (!editor || !ctx)
96
102
  return;
97
103
  try {
98
- // SAFETY: theme is live pi TUI theme read at render time
99
- const theme = ctx.ui.theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime // SAFETY: pi seam
104
+ // SAFETY: theme is live pi TUI theme read at render time — intentional unsafe cast, validated at runtime
105
+ const theme = ctx.ui.theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
100
106
  const snap = this.deps.runActivityTracker.getSnapshot();
101
- const text = formatRunActivityTopRight(snap, theme);
107
+ let text = formatRunActivityTopRight(snap, theme);
108
+ // Relocate stall to the right of tool use with pipe separator — agent-run live (option B), not bottom telemetry
109
+ if (cfg.telemetry.enabled && cfg.telemetry.stalls) {
110
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for stall (agent run, option B)
111
+ const tracker = this.deps.telemetryTracker;
112
+ const tel = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
113
+ if (tel && tel.stallMs > 0) {
114
+ const glyphs = resolveGlyphs(cfg.icons.mode);
115
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg
116
+ const stallText = theme.fg("warning", `${glyphs.stall}${tel.stallCount}×${formatTurnDuration(tel.stallMs).trim()}`);
117
+ if (text) {
118
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg for pipe
119
+ const pipe = theme.fg("dim", " | ");
120
+ text = `${text}${pipe}${stallText}`;
121
+ }
122
+ else {
123
+ text = stallText;
124
+ }
125
+ }
126
+ }
102
127
  editor.setTopRightText(text);
103
128
  }
104
129
  catch {
@@ -122,15 +147,17 @@ export class LiveBorder {
122
147
  return;
123
148
  }
124
149
  try {
125
- // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha)
150
+ // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha) — intentional unsafe cast, validated at runtime
126
151
  const live = this.deps.telemetryTracker.peekLive() ??
127
152
  this.deps.telemetryTracker.getLastTelemetry();
128
153
  if (!live)
129
154
  return;
130
- // SAFETY: pi TUI seam read-only - theme from extension context
131
- const theme = ctx?.ui?.theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
155
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme from extension context
156
+ const theme = ctx?.ui?.theme;
132
157
  const glyphs = resolveGlyphs(cfg.icons.mode);
133
- const right = formatTurnTelemetry(live, theme, cfg.telemetry, glyphs);
158
+ // Stall relocated to top right of tool use with pipe — suppress in bottom telemetry
159
+ const bottomCfg = { ...cfg.telemetry, stalls: false };
160
+ const right = formatTurnTelemetry(live, theme, bottomCfg, glyphs);
134
161
  if (live.totalMs > 0) {
135
162
  editor.setTelemetryText(right);
136
163
  editor.setBottomLeftText("");
@@ -169,11 +196,40 @@ export class LiveBorder {
169
196
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
170
197
  let tokensText = "";
171
198
  if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
172
- // SAFETY: pi TUI seam - telemetry tokens for top tokens line
173
- const live = this.deps.telemetryTracker.peekLive() ??
174
- this.deps.telemetryTracker.getLastTelemetry();
175
- if (live) {
176
- tokensText = formatTelemetryTokens(live, theme, cfg.telemetry, glyphs);
199
+ const isRunning = this.deps.runActivityTracker.isRunning();
200
+ // When idle (settled) and baseline available, show per-agent delta (input/output/cost) = cur totals - baseline at agent_start.
201
+ // This yields 18k for the example (279k session - 261k baseline = 18k agent) instead of 279k total.
202
+ // When running, use live agent tracker (sum of turns in this agent + live turn) which is already per-agent after guard fix.
203
+ if (!isRunning && this.agentBaseline) {
204
+ const cur = snapshot.totals;
205
+ const base = this.agentBaseline;
206
+ const deltaInput = Math.max(0, cur.input - base.input);
207
+ const deltaOutput = Math.max(0, cur.output - base.output);
208
+ // Build minimal telemetry for formatting (only tokens matter for formatTelemetryTokens)
209
+ const deltaTel = {
210
+ tps: null,
211
+ ttftMs: 0,
212
+ totalMs: 0,
213
+ inputTokens: deltaInput,
214
+ outputTokens: deltaOutput,
215
+ stallMs: 0,
216
+ stallCount: 0,
217
+ rateUsdPerMTokens: null,
218
+ generationMs: 0,
219
+ totalTokens: deltaInput + deltaOutput,
220
+ costUsd: Math.max(0, cur.cost - base.cost),
221
+ measurementMs: null,
222
+ };
223
+ tokensText = formatTelemetryTokens(deltaTel, theme, cfg.telemetry, glyphs);
224
+ }
225
+ else {
226
+ // Live or fallback — per-agent sum from tracker (option B), correctly reset per agent_start
227
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for top tokens line (agent run)
228
+ const tracker = this.deps.telemetryTracker;
229
+ const live = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
230
+ if (live) {
231
+ tokensText = formatTelemetryTokens(live, theme, cfg.telemetry, glyphs);
232
+ }
177
233
  }
178
234
  }
179
235
  editor.setTopContextText(contextText);
package/dist/telemetry.js CHANGED
@@ -50,6 +50,7 @@ export class TurnTelemetryTracker {
50
50
  agentStartMs = null;
51
51
  agentTurns = [];
52
52
  lastTelemetry = null;
53
+ lastTurnTelemetry = null;
53
54
  decayBaseTps = null;
54
55
  decayStartMs = null;
55
56
  constructor(now = () => performance.now()) {
@@ -58,6 +59,78 @@ export class TurnTelemetryTracker {
58
59
  getLastTelemetry() {
59
60
  return this.lastTelemetry;
60
61
  }
62
+ getLastTurnTelemetry() {
63
+ return this.lastTurnTelemetry;
64
+ }
65
+ /** Agent-run live: cumulative across turns in current agent, including current live turn. Option B. */
66
+ peekAgentLive() {
67
+ // No agent active -> show last agent sum (option B) or current live turn if any
68
+ if (this.agentStartMs === null) {
69
+ const live = this.peekLive();
70
+ if (live)
71
+ return live;
72
+ return this.lastTelemetry ?? this.lastTurnTelemetry;
73
+ }
74
+ const live = this.peekLive();
75
+ const hasCompleted = this.agentTurns.length > 0;
76
+ if (!hasCompleted && !live)
77
+ return null;
78
+ let inputTokens = 0;
79
+ let outputTokens = 0;
80
+ let totalTokens = 0;
81
+ let costUsd = 0;
82
+ let stallMs = 0;
83
+ let stallCount = 0;
84
+ let generationMs = 0;
85
+ let ttftMs = 0;
86
+ // sum completed turns
87
+ for (const t of this.agentTurns) {
88
+ inputTokens += t.inputTokens;
89
+ outputTokens += t.outputTokens;
90
+ totalTokens += t.totalTokens;
91
+ costUsd += t.costUsd;
92
+ stallMs += t.stallMs;
93
+ stallCount += t.stallCount;
94
+ generationMs += t.generationMs;
95
+ }
96
+ if (this.agentTurns.length > 0)
97
+ ttftMs = this.agentTurns[0].ttftMs;
98
+ if (live) {
99
+ inputTokens += live.inputTokens;
100
+ outputTokens += live.outputTokens;
101
+ totalTokens += live.totalTokens;
102
+ costUsd += live.costUsd;
103
+ stallMs += live.stallMs;
104
+ stallCount += live.stallCount;
105
+ generationMs += live.generationMs;
106
+ if (ttftMs === 0)
107
+ ttftMs = live.ttftMs;
108
+ }
109
+ const now = this.now();
110
+ const totalMs = Math.max(0, now - this.agentStartMs);
111
+ const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
112
+ const tps = measurementMs === null
113
+ ? null
114
+ : round(outputTokens / (measurementMs / 1000), 1);
115
+ const validCost = Number.isFinite(costUsd) && costUsd > 0;
116
+ const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
117
+ return {
118
+ tps,
119
+ ttftMs,
120
+ totalMs,
121
+ inputTokens,
122
+ outputTokens,
123
+ stallMs,
124
+ stallCount,
125
+ rateUsdPerMTokens: validCost && validTokens
126
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
127
+ : null,
128
+ generationMs,
129
+ totalTokens,
130
+ costUsd: validCost ? costUsd : 0,
131
+ measurementMs,
132
+ };
133
+ }
61
134
  /** Live snapshot while a turn is running — for real-time border refresh. Returns null when idle. */
62
135
  peekLive() {
63
136
  const turn = this.turn;
@@ -152,11 +225,10 @@ export class TurnTelemetryTracker {
152
225
  handle(event) {
153
226
  switch (event.type) {
154
227
  case "agent_start":
155
- if (this.agentStartMs === null) {
156
- this.agentStartMs = this.now();
157
- this.agentTurns = [];
158
- }
228
+ this.agentStartMs = this.now();
229
+ this.agentTurns = [];
159
230
  return;
231
+ case "agent_end":
160
232
  case "agent_settled":
161
233
  return this.endAgent();
162
234
  case "turn_start":
@@ -269,8 +341,10 @@ export class TurnTelemetryTracker {
269
341
  const telemetry = this.endTurn();
270
342
  if (telemetry && this.agentStartMs !== null)
271
343
  this.agentTurns.push(telemetry);
272
- if (telemetry)
344
+ if (telemetry) {
345
+ this.lastTurnTelemetry = telemetry;
273
346
  this.lastTelemetry = telemetry;
347
+ }
274
348
  return telemetry;
275
349
  }
276
350
  endTurn() {
@@ -351,10 +425,11 @@ export class TurnTelemetryTracker {
351
425
  measurementMs,
352
426
  };
353
427
  this.lastTelemetry = result;
428
+ // keep lastTurnTelemetry as last turn's per-turn telemetry (live input stays per-turn, not agent total)
354
429
  return result;
355
430
  }
356
431
  }
357
- function formatTurnDuration(ms) {
432
+ export function formatTurnDuration(ms) {
358
433
  if (ms < 60_000) {
359
434
  // TTFT fixed width: 2-digit integer + one decimal -> padded 4 + "s" =5, then overall duration field padded to 7 for telemetry totalMs
360
435
  // For TTFT we want 5, for duration we want 7 — caller will pad accordingly, so here return 5 for <60s case
package/package.json CHANGED
@@ -18,8 +18,7 @@
18
18
  "@earendil-works/pi-coding-agent": "*",
19
19
  "@earendil-works/pi-tui": "*"
20
20
  },
21
- "version": "0.5.0",
22
- "main": "./dist/index.js",
21
+ "version": "0.6.1",
23
22
  "files": [
24
23
  "dist",
25
24
  "src",
@@ -8,11 +8,19 @@ export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
8
8
  }
9
9
 
10
10
  export function contextUsageColor(pct: number): ThemeColor {
11
- // 25% 50% 75% thresholds — transit from dimmed (low) to highlight (high)
12
- // for intuitive quota status: dim (<25) → accent (25-50) → warning (50-75) → error (≥75)
13
- if (pct >= 75) return "error";
14
- if (pct >= 50) return "warning";
15
- if (pct >= 25) return "accent";
11
+ // 12.5 / 25 / 50 quotas — four urgency tiers, increasingly aggressive as context fills.
12
+ // 0 – 12.5% dim — plenty of headroom, visually quiet.
13
+ // 12.5 – 25% accent — first nudge, noticeable but calm.
14
+ // 25 – 50% warning — half consumed, needs attention.
15
+ // 50 – 100% error — critical, about to run out.
16
+ // Uses theme semantic tokens (dim/accent/warning/error) so the progression
17
+ // respects the active theme and remains legible on light/dark/custom palettes.
18
+ // A fixed hex palette (e.g. grey→sky→amber→red) would be more vivid but
19
+ // would ignore the user's theme and can clash with light backgrounds —
20
+ // semantic tokens keep the "aggressive" ordering while staying theme-coherent.
21
+ if (pct >= 50) return "error";
22
+ if (pct >= 25) return "warning";
23
+ if (pct >= 12.5) return "accent";
16
24
  return "dim";
17
25
  }
18
26
 
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
- getLastSessionCtx: () => lastSessionCtx,
126
- getTuiRef: () => tuiRef,
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
- // SAFETY: best-effort, ignore recoverable error
271
+ // SAFETY: best-effort, ignore recoverable error
271
272
  return d.toLocaleString();
272
273
  }
273
274
  }
274
275
 
275
276
  function injectTimelineDimLine(
276
- _ctx: ExtensionUIContextLike,
277
- rawLine: string,
277
+ _ctx: ExtensionUIContextLike,
278
+ rawLine: string,
278
279
  ): void {
279
- // Use pi.appendEntry so it is interleaved in chat container (not aboveEditor stacked widget)
280
- try {
281
- // SAFETY: pi custom entry is TUI-only, not sent to LLM
282
- (extensionPi as unknown as { appendEntry?: (t:string,d:unknown)=>void })?.appendEntry?.("timeline", { text: rawLine });
283
- } catch {// SAFETY: best-effort, ignore recoverable error
284
- // SAFETY: best-effort, ignore recoverable error
285
- }
286
- // keep legacy array in sync
287
- wallTimeHistory = [...wallTimeHistory, rawLine];
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
- // No widget to clear — entries are interleaved and persist with session
291
- wallTimeHistory = [];
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
- // Timeline now uses pi.appendEntry("timeline") interleaved in chat container — not aboveEditor widget
439
- // Register renderer for timeline custom entries (dim, left-aligned, interleaved)
440
- try {
441
- // SAFETY: pi entry renderer is public API — timeline entries are TUI-only, not sent to LLM
442
- (pi as unknown as { registerEntryRenderer?: (t:string, r:unknown)=>void }).registerEntryRenderer?.(
443
- "timeline",
444
- (entry: unknown, _opts: unknown, theme: unknown) => {
445
- const data = (entry as { data?: { text?: string } }).data;
446
- const text = data?.text ?? "";
447
- const lines = text.split("\n").map((l: string) => {
448
- try {
449
- return (theme as { fg: (c:string,s:string)=>string }).fg("dim", " " + l);
450
- } catch {
451
- // SAFETY: best-effort, ignore recoverable error
452
- return " " + l;
453
- }
454
- });
455
- // SAFETY: intentional unsafe cast — validated at runtime
456
- return new Text(lines.join("\n")) as unknown as Component;
457
- },
458
- );
459
- } catch {// SAFETY: best-effort, ignore recoverable error
460
- // SAFETY: best-effort, ignore recoverable error
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
- pi.on("session_shutdown", () => {
707
- // timeline entries are custom entries interleaved — no aboveEditor widget to clear
708
- wallTimeHistory = [];
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,6 +794,11 @@ 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 = (
@@ -864,10 +895,24 @@ export default function (pi: ExtensionAPILike): void {
864
895
  const cacheRate = totals.latestCacheHitRate ?? 0;
865
896
  const cacheStr = `${glyphs.cacheHit} ${cacheRate.toFixed(1)}%`;
866
897
  // Respect timeline.* toggles for specified metrics (wallTime/tokens/cost), but datetime/cache/turn/tools are always shown per user spec
867
- // Timeline tokens now mirror telemetry (per-agent) not session totals — input known at turn_start via liveInputTokens
868
- const telInput = tel ? tel.inputTokens : totals.input;
869
- const telOutput = tel ? tel.outputTokens : totals.output;
870
- const telCost = tel ? tel.costUsd : totals.cost;
898
+ // Timeline tokens per-agent: prefer baseline delta (totals - baseline) which yields 18k (279k-261k) not 279k total.
899
+ // Fallback to tel (tracker sum) then session totals.
900
+ let telInput: number;
901
+ let telOutput: number;
902
+ let telCost: number;
903
+ if (agentBaselineTotals) {
904
+ telInput = Math.max(0, totals.input - agentBaselineTotals.input);
905
+ telOutput = Math.max(0, totals.output - agentBaselineTotals.output);
906
+ telCost = Math.max(0, totals.cost - agentBaselineTotals.cost);
907
+ } else if (tel) {
908
+ telInput = tel.inputTokens;
909
+ telOutput = tel.outputTokens;
910
+ telCost = tel.costUsd;
911
+ } else {
912
+ telInput = totals.input;
913
+ telOutput = totals.output;
914
+ telCost = totals.cost;
915
+ }
871
916
  const line1Parts: string[] = [dt];
872
917
  if (currentConfig.timeline.wallTime) line1Parts.push(wallDur);
873
918
  else line1Parts.push(wallDur); // wall time always per spec (11s)
@@ -894,9 +939,10 @@ export default function (pi: ExtensionAPILike): void {
894
939
  wallText,
895
940
  );
896
941
  }
897
- } catch {// SAFETY: best-effort, ignore recoverable error
898
- // SAFETY: best-effort, ignore recoverable error
899
- }
942
+ } catch {
943
+ // SAFETY: best-effort, ignore recoverable error
944
+ // SAFETY: best-effort, ignore recoverable error
945
+ }
900
946
  // final settled telemetry overwrites live peek with authoritative totals
901
947
  if (tel && installedEditor && currentConfig.telemetry.enabled) {
902
948
  try {
@@ -18,7 +18,12 @@ import {
18
18
  import { resolveGlyphs, resolveIconMode } from "./icons.js";
19
19
  import { formatRunActivityTopRight } from "./run-activity.js";
20
20
  import type { RunActivityTracker } from "./run-activity.js";
21
- import { formatTelemetryTokens, formatTurnTelemetry } from "./telemetry.js";
21
+ import {
22
+ formatTelemetryTokens,
23
+ formatTurnDuration,
24
+ formatTurnTelemetry,
25
+ } from "./telemetry.js";
26
+ import type { UsageTotals } from "./state.js";
22
27
  import type { TurnTelemetry } from "./telemetry.js";
23
28
  import type { TurnTelemetryTracker } from "./telemetry.js";
24
29
  import type { ThemeConfig } from "./config.js";
@@ -39,9 +44,15 @@ export class LiveBorder {
39
44
  private timer: ReturnType<typeof setInterval> | null = null;
40
45
  private lastRenderMs = 0;
41
46
  private pendingRender: ReturnType<typeof setTimeout> | null = null;
47
+ private agentBaseline: UsageTotals | null = null;
42
48
 
43
49
  constructor(private readonly deps: LiveBorderDeps) {}
44
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
+
45
56
  /** Coalesced render: top (run-activity) + bottom (telemetry) + context bar → editor. */
46
57
  render(): void {
47
58
  const now = Date.now();
@@ -113,14 +124,43 @@ export class LiveBorder {
113
124
  private refreshTopBorder(): void {
114
125
  const editor = this.deps.getEditor();
115
126
  const ctx = this.deps.getCtx();
127
+ const cfg = this.deps.getConfig();
116
128
  if (!editor || !ctx) return;
117
129
  try {
118
- // SAFETY: theme is live pi TUI theme read at render time
130
+ // SAFETY: theme is live pi TUI theme read at render time — intentional unsafe cast, validated at runtime
119
131
  const theme = (
120
132
  ctx.ui as unknown as { theme: { fg(s: string, t: string): string } }
121
- ).theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime // SAFETY: pi seam
133
+ ).theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
122
134
  const snap = this.deps.runActivityTracker.getSnapshot();
123
- const text = formatRunActivityTopRight(snap, theme as never);
135
+ let text = formatRunActivityTopRight(snap, theme as never);
136
+ // Relocate stall to the right of tool use with pipe separator — agent-run live (option B), not bottom telemetry
137
+ if (cfg.telemetry.enabled && cfg.telemetry.stalls) {
138
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for stall (agent run, option B)
139
+ const tracker = this.deps.telemetryTracker as unknown as {
140
+ peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
141
+ getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
142
+ };
143
+ const tel = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
144
+ if (tel && tel.stallMs > 0) {
145
+ const glyphs = resolveGlyphs(cfg.icons.mode);
146
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg
147
+ const stallText = (
148
+ theme as unknown as { fg: (s: string, t: string) => string }
149
+ ).fg(
150
+ "warning",
151
+ `${glyphs.stall}${tel.stallCount}×${formatTurnDuration(tel.stallMs).trim()}`,
152
+ );
153
+ if (text) {
154
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg for pipe
155
+ const pipe = (
156
+ theme as unknown as { fg: (s: string, t: string) => string }
157
+ ).fg("dim", " | ");
158
+ text = `${text}${pipe}${stallText}`;
159
+ } else {
160
+ text = stallText;
161
+ }
162
+ }
163
+ }
124
164
  editor.setTopRightText(text);
125
165
  } catch {
126
166
  // SAFETY: best-effort UI, ignore recoverable error
@@ -142,18 +182,20 @@ export class LiveBorder {
142
182
  return;
143
183
  }
144
184
  try {
145
- // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha)
185
+ // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha) — intentional unsafe cast, validated at runtime
146
186
  const live =
147
187
  this.deps.telemetryTracker.peekLive() ??
148
188
  this.deps.telemetryTracker.getLastTelemetry();
149
189
  if (!live) return;
150
- // SAFETY: pi TUI seam read-only - theme from extension context
151
- const theme = (ctx as unknown as { ui?: { theme?: unknown } })?.ui?.theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
190
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme from extension context
191
+ const theme = (ctx as unknown as { ui?: { theme?: unknown } })?.ui?.theme;
152
192
  const glyphs = resolveGlyphs(cfg.icons.mode);
193
+ // Stall relocated to top right of tool use with pipe — suppress in bottom telemetry
194
+ const bottomCfg = { ...cfg.telemetry, stalls: false };
153
195
  const right = formatTurnTelemetry(
154
196
  live,
155
197
  theme as never,
156
- cfg.telemetry,
198
+ bottomCfg as never,
157
199
  glyphs as never,
158
200
  );
159
201
  if (live.totalMs > 0) {
@@ -202,17 +244,52 @@ export class LiveBorder {
202
244
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
203
245
  let tokensText = "";
204
246
  if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
205
- // SAFETY: pi TUI seam - telemetry tokens for top tokens line
206
- const live =
207
- this.deps.telemetryTracker.peekLive() ??
208
- this.deps.telemetryTracker.getLastTelemetry();
209
- if (live) {
247
+ const isRunning = this.deps.runActivityTracker.isRunning();
248
+ // When idle (settled) and baseline available, show per-agent delta (input/output/cost) = cur totals - baseline at agent_start.
249
+ // This yields 18k for the example (279k session - 261k baseline = 18k agent) instead of 279k total.
250
+ // When running, use live agent tracker (sum of turns in this agent + live turn) which is already per-agent after guard fix.
251
+ if (!isRunning && this.agentBaseline) {
252
+ const cur = snapshot.totals;
253
+ const base = this.agentBaseline;
254
+ const deltaInput = Math.max(0, cur.input - base.input);
255
+ const deltaOutput = Math.max(0, cur.output - base.output);
256
+ // Build minimal telemetry for formatting (only tokens matter for formatTelemetryTokens)
257
+ const deltaTel: TurnTelemetry = {
258
+ tps: null,
259
+ ttftMs: 0,
260
+ totalMs: 0,
261
+ inputTokens: deltaInput,
262
+ outputTokens: deltaOutput,
263
+ stallMs: 0,
264
+ stallCount: 0,
265
+ rateUsdPerMTokens: null,
266
+ generationMs: 0,
267
+ totalTokens: deltaInput + deltaOutput,
268
+ costUsd: Math.max(0, cur.cost - base.cost),
269
+ measurementMs: null,
270
+ };
210
271
  tokensText = formatTelemetryTokens(
211
- live,
272
+ deltaTel,
212
273
  theme as never,
213
274
  cfg.telemetry,
214
275
  glyphs as never,
215
276
  );
277
+ } else {
278
+ // Live or fallback — per-agent sum from tracker (option B), correctly reset per agent_start
279
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for top tokens line (agent run)
280
+ const tracker = this.deps.telemetryTracker as unknown as {
281
+ peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
282
+ getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
283
+ };
284
+ const live = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
285
+ if (live) {
286
+ tokensText = formatTelemetryTokens(
287
+ live,
288
+ theme as never,
289
+ cfg.telemetry,
290
+ glyphs as never,
291
+ );
292
+ }
216
293
  }
217
294
  }
218
295
  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";
@@ -150,9 +151,9 @@ export class TurnTelemetryTracker {
150
151
  private agentStartMs: number | null = null;
151
152
  private agentTurns: TurnTelemetry[] = [];
152
153
  private lastTelemetry: TurnTelemetry | null = null;
154
+ private lastTurnTelemetry: TurnTelemetry | null = null;
153
155
  private decayBaseTps: number | null = null;
154
156
  private decayStartMs: number | null = null;
155
-
156
157
  constructor(now: () => number = () => performance.now()) {
157
158
  this.now = now;
158
159
  }
@@ -160,6 +161,79 @@ export class TurnTelemetryTracker {
160
161
  getLastTelemetry(): TurnTelemetry | null {
161
162
  return this.lastTelemetry;
162
163
  }
164
+
165
+ getLastTurnTelemetry(): TurnTelemetry | null {
166
+ return this.lastTurnTelemetry;
167
+ }
168
+
169
+ /** Agent-run live: cumulative across turns in current agent, including current live turn. Option B. */
170
+ peekAgentLive(): TurnTelemetry | null {
171
+ // No agent active -> show last agent sum (option B) or current live turn if any
172
+ if (this.agentStartMs === null) {
173
+ const live = this.peekLive();
174
+ if (live) return live;
175
+ return this.lastTelemetry ?? this.lastTurnTelemetry;
176
+ }
177
+ const live = this.peekLive();
178
+ const hasCompleted = this.agentTurns.length > 0;
179
+ if (!hasCompleted && !live) return null;
180
+ let inputTokens = 0;
181
+ let outputTokens = 0;
182
+ let totalTokens = 0;
183
+ let costUsd = 0;
184
+ let stallMs = 0;
185
+ let stallCount = 0;
186
+ let generationMs = 0;
187
+ let ttftMs = 0;
188
+ // sum completed turns
189
+ for (const t of this.agentTurns) {
190
+ inputTokens += t.inputTokens;
191
+ outputTokens += t.outputTokens;
192
+ totalTokens += t.totalTokens;
193
+ costUsd += t.costUsd;
194
+ stallMs += t.stallMs;
195
+ stallCount += t.stallCount;
196
+ generationMs += t.generationMs;
197
+ }
198
+ if (this.agentTurns.length > 0) ttftMs = this.agentTurns[0]!.ttftMs;
199
+ if (live) {
200
+ inputTokens += live.inputTokens;
201
+ outputTokens += live.outputTokens;
202
+ totalTokens += live.totalTokens;
203
+ costUsd += live.costUsd;
204
+ stallMs += live.stallMs;
205
+ stallCount += live.stallCount;
206
+ generationMs += live.generationMs;
207
+ if (ttftMs === 0) ttftMs = live.ttftMs;
208
+ }
209
+ const now = this.now();
210
+ const totalMs = Math.max(0, now - this.agentStartMs);
211
+ const measurementMs =
212
+ outputTokens > 0 && generationMs > 0 ? generationMs : null;
213
+ const tps =
214
+ measurementMs === null
215
+ ? null
216
+ : round(outputTokens / (measurementMs / 1000), 1);
217
+ const validCost = Number.isFinite(costUsd) && costUsd > 0;
218
+ const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
219
+ return {
220
+ tps,
221
+ ttftMs,
222
+ totalMs,
223
+ inputTokens,
224
+ outputTokens,
225
+ stallMs,
226
+ stallCount,
227
+ rateUsdPerMTokens:
228
+ validCost && validTokens
229
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
230
+ : null,
231
+ generationMs,
232
+ totalTokens,
233
+ costUsd: validCost ? costUsd : 0,
234
+ measurementMs,
235
+ };
236
+ }
163
237
  /** Live snapshot while a turn is running — for real-time border refresh. Returns null when idle. */
164
238
  peekLive(): TurnTelemetry | null {
165
239
  const turn = this.turn;
@@ -256,11 +330,10 @@ export class TurnTelemetryTracker {
256
330
  handle(event: TelemetryEvent): TurnTelemetry | undefined {
257
331
  switch (event.type) {
258
332
  case "agent_start":
259
- if (this.agentStartMs === null) {
260
- this.agentStartMs = this.now();
261
- this.agentTurns = [];
262
- }
333
+ this.agentStartMs = this.now();
334
+ this.agentTurns = [];
263
335
  return;
336
+ case "agent_end":
264
337
  case "agent_settled":
265
338
  return this.endAgent();
266
339
  case "turn_start":
@@ -386,7 +459,10 @@ export class TurnTelemetryTracker {
386
459
  const telemetry = this.endTurn();
387
460
  if (telemetry && this.agentStartMs !== null)
388
461
  this.agentTurns.push(telemetry);
389
- if (telemetry) this.lastTelemetry = telemetry;
462
+ if (telemetry) {
463
+ this.lastTurnTelemetry = telemetry;
464
+ this.lastTelemetry = telemetry;
465
+ }
390
466
  return telemetry;
391
467
  }
392
468
 
@@ -478,11 +554,12 @@ export class TurnTelemetryTracker {
478
554
  measurementMs,
479
555
  };
480
556
  this.lastTelemetry = result;
557
+ // keep lastTurnTelemetry as last turn's per-turn telemetry (live input stays per-turn, not agent total)
481
558
  return result;
482
559
  }
483
560
  }
484
561
 
485
- function formatTurnDuration(ms: number): string {
562
+ export function formatTurnDuration(ms: number): string {
486
563
  if (ms < 60_000) {
487
564
  // TTFT fixed width: 2-digit integer + one decimal -> padded 4 + "s" =5, then overall duration field padded to 7 for telemetry totalMs
488
565
  // For TTFT we want 5, for duration we want 7 — caller will pad accordingly, so here return 5 for <60s case