pi-editor-footer 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-08-24
8
+
9
+ ### Changed
10
+
11
+ - 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
12
+ - 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
13
+ - 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`)
14
+
7
15
  ## [0.5.0] - 2026-08-22
8
16
 
9
17
  ### 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 quotasfour 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
  }
@@ -13,7 +13,7 @@
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;
@@ -92,13 +92,33 @@ export class LiveBorder {
92
92
  refreshTopBorder() {
93
93
  const editor = this.deps.getEditor();
94
94
  const ctx = this.deps.getCtx();
95
+ const cfg = this.deps.getConfig();
95
96
  if (!editor || !ctx)
96
97
  return;
97
98
  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
99
+ // SAFETY: theme is live pi TUI theme read at render time — intentional unsafe cast, validated at runtime
100
+ const theme = ctx.ui.theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
100
101
  const snap = this.deps.runActivityTracker.getSnapshot();
101
- const text = formatRunActivityTopRight(snap, theme);
102
+ let text = formatRunActivityTopRight(snap, theme);
103
+ // Relocate stall to the right of tool use with pipe separator — agent-run live (option B), not bottom telemetry
104
+ if (cfg.telemetry.enabled && cfg.telemetry.stalls) {
105
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for stall (agent run, option B)
106
+ const tracker = this.deps.telemetryTracker;
107
+ const tel = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
108
+ if (tel && tel.stallMs > 0) {
109
+ const glyphs = resolveGlyphs(cfg.icons.mode);
110
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg
111
+ const stallText = theme.fg("warning", `${glyphs.stall}${tel.stallCount}×${formatTurnDuration(tel.stallMs).trim()}`);
112
+ if (text) {
113
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg for pipe
114
+ const pipe = theme.fg("dim", " | ");
115
+ text = `${text}${pipe}${stallText}`;
116
+ }
117
+ else {
118
+ text = stallText;
119
+ }
120
+ }
121
+ }
102
122
  editor.setTopRightText(text);
103
123
  }
104
124
  catch {
@@ -122,15 +142,17 @@ export class LiveBorder {
122
142
  return;
123
143
  }
124
144
  try {
125
- // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha)
145
+ // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha) — intentional unsafe cast, validated at runtime
126
146
  const live = this.deps.telemetryTracker.peekLive() ??
127
147
  this.deps.telemetryTracker.getLastTelemetry();
128
148
  if (!live)
129
149
  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
150
+ // SAFETY: pi seam intentional unsafe cast, validated at runtime — theme from extension context
151
+ const theme = ctx?.ui?.theme;
132
152
  const glyphs = resolveGlyphs(cfg.icons.mode);
133
- const right = formatTurnTelemetry(live, theme, cfg.telemetry, glyphs);
153
+ // Stall relocated to top right of tool use with pipe — suppress in bottom telemetry
154
+ const bottomCfg = { ...cfg.telemetry, stalls: false };
155
+ const right = formatTurnTelemetry(live, theme, bottomCfg, glyphs);
134
156
  if (live.totalMs > 0) {
135
157
  editor.setTelemetryText(right);
136
158
  editor.setBottomLeftText("");
@@ -169,9 +191,10 @@ export class LiveBorder {
169
191
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
170
192
  let tokensText = "";
171
193
  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();
194
+ // Live input/output per agent run (option B) cumulative across turns in this agent, like live output
195
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for top tokens line (agent run)
196
+ const tracker = this.deps.telemetryTracker;
197
+ const live = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
175
198
  if (live) {
176
199
  tokensText = formatTelemetryTokens(live, theme, cfg.telemetry, glyphs);
177
200
  }
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;
@@ -269,8 +342,10 @@ export class TurnTelemetryTracker {
269
342
  const telemetry = this.endTurn();
270
343
  if (telemetry && this.agentStartMs !== null)
271
344
  this.agentTurns.push(telemetry);
272
- if (telemetry)
345
+ if (telemetry) {
346
+ this.lastTurnTelemetry = telemetry;
273
347
  this.lastTelemetry = telemetry;
348
+ }
274
349
  return telemetry;
275
350
  }
276
351
  endTurn() {
@@ -351,10 +426,11 @@ export class TurnTelemetryTracker {
351
426
  measurementMs,
352
427
  };
353
428
  this.lastTelemetry = result;
429
+ // keep lastTurnTelemetry as last turn's per-turn telemetry (live input stays per-turn, not agent total)
354
430
  return result;
355
431
  }
356
432
  }
357
- function formatTurnDuration(ms) {
433
+ export function formatTurnDuration(ms) {
358
434
  if (ms < 60_000) {
359
435
  // TTFT fixed width: 2-digit integer + one decimal -> padded 4 + "s" =5, then overall duration field padded to 7 for telemetry totalMs
360
436
  // 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,7 +18,7 @@
18
18
  "@earendil-works/pi-coding-agent": "*",
19
19
  "@earendil-works/pi-tui": "*"
20
20
  },
21
- "version": "0.5.0",
21
+ "version": "0.6.0",
22
22
  "main": "./dist/index.js",
23
23
  "files": [
24
24
  "dist",
@@ -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 quotasfour 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
 
@@ -18,7 +18,11 @@ 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";
22
26
  import type { TurnTelemetry } from "./telemetry.js";
23
27
  import type { TurnTelemetryTracker } from "./telemetry.js";
24
28
  import type { ThemeConfig } from "./config.js";
@@ -113,14 +117,43 @@ export class LiveBorder {
113
117
  private refreshTopBorder(): void {
114
118
  const editor = this.deps.getEditor();
115
119
  const ctx = this.deps.getCtx();
120
+ const cfg = this.deps.getConfig();
116
121
  if (!editor || !ctx) return;
117
122
  try {
118
- // SAFETY: theme is live pi TUI theme read at render time
123
+ // SAFETY: theme is live pi TUI theme read at render time — intentional unsafe cast, validated at runtime
119
124
  const theme = (
120
125
  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
126
+ ).theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
122
127
  const snap = this.deps.runActivityTracker.getSnapshot();
123
- const text = formatRunActivityTopRight(snap, theme as never);
128
+ let text = formatRunActivityTopRight(snap, theme as never);
129
+ // Relocate stall to the right of tool use with pipe separator — agent-run live (option B), not bottom telemetry
130
+ if (cfg.telemetry.enabled && cfg.telemetry.stalls) {
131
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for stall (agent run, option B)
132
+ const tracker = this.deps.telemetryTracker as unknown as {
133
+ peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
134
+ getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
135
+ };
136
+ const tel = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
137
+ if (tel && tel.stallMs > 0) {
138
+ const glyphs = resolveGlyphs(cfg.icons.mode);
139
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg
140
+ const stallText = (
141
+ theme as unknown as { fg: (s: string, t: string) => string }
142
+ ).fg(
143
+ "warning",
144
+ `${glyphs.stall}${tel.stallCount}×${formatTurnDuration(tel.stallMs).trim()}`,
145
+ );
146
+ if (text) {
147
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — theme fg for pipe
148
+ const pipe = (
149
+ theme as unknown as { fg: (s: string, t: string) => string }
150
+ ).fg("dim", " | ");
151
+ text = `${text}${pipe}${stallText}`;
152
+ } else {
153
+ text = stallText;
154
+ }
155
+ }
156
+ }
124
157
  editor.setTopRightText(text);
125
158
  } catch {
126
159
  // SAFETY: best-effort UI, ignore recoverable error
@@ -142,18 +175,20 @@ export class LiveBorder {
142
175
  return;
143
176
  }
144
177
  try {
145
- // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha)
178
+ // SAFETY: peekLive ?? getLastTelemetry preserves cost after toggle (AGENTS.md gotcha) — intentional unsafe cast, validated at runtime
146
179
  const live =
147
180
  this.deps.telemetryTracker.peekLive() ??
148
181
  this.deps.telemetryTracker.getLastTelemetry();
149
182
  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
183
+ // SAFETY: pi seam intentional unsafe cast, validated at runtime — theme from extension context
184
+ const theme = (ctx as unknown as { ui?: { theme?: unknown } })?.ui?.theme;
152
185
  const glyphs = resolveGlyphs(cfg.icons.mode);
186
+ // Stall relocated to top right of tool use with pipe — suppress in bottom telemetry
187
+ const bottomCfg = { ...cfg.telemetry, stalls: false };
153
188
  const right = formatTurnTelemetry(
154
189
  live,
155
190
  theme as never,
156
- cfg.telemetry,
191
+ bottomCfg as never,
157
192
  glyphs as never,
158
193
  );
159
194
  if (live.totalMs > 0) {
@@ -202,10 +237,14 @@ export class LiveBorder {
202
237
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
203
238
  let tokensText = "";
204
239
  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();
240
+ // Live input/output per agent run (option B) cumulative across turns in this agent, like live output
241
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime — telemetry tracker for top tokens line (agent run)
242
+ const tracker = this.deps.telemetryTracker as unknown as {
243
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
244
+ peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
245
+ getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
246
+ };
247
+ const live = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
209
248
  if (live) {
210
249
  tokensText = formatTelemetryTokens(
211
250
  live,
package/src/telemetry.ts CHANGED
@@ -150,9 +150,9 @@ export class TurnTelemetryTracker {
150
150
  private agentStartMs: number | null = null;
151
151
  private agentTurns: TurnTelemetry[] = [];
152
152
  private lastTelemetry: TurnTelemetry | null = null;
153
+ private lastTurnTelemetry: TurnTelemetry | null = null;
153
154
  private decayBaseTps: number | null = null;
154
155
  private decayStartMs: number | null = null;
155
-
156
156
  constructor(now: () => number = () => performance.now()) {
157
157
  this.now = now;
158
158
  }
@@ -160,6 +160,79 @@ export class TurnTelemetryTracker {
160
160
  getLastTelemetry(): TurnTelemetry | null {
161
161
  return this.lastTelemetry;
162
162
  }
163
+
164
+ getLastTurnTelemetry(): TurnTelemetry | null {
165
+ return this.lastTurnTelemetry;
166
+ }
167
+
168
+ /** Agent-run live: cumulative across turns in current agent, including current live turn. Option B. */
169
+ peekAgentLive(): TurnTelemetry | null {
170
+ // No agent active -> show last agent sum (option B) or current live turn if any
171
+ if (this.agentStartMs === null) {
172
+ const live = this.peekLive();
173
+ if (live) return live;
174
+ return this.lastTelemetry ?? this.lastTurnTelemetry;
175
+ }
176
+ const live = this.peekLive();
177
+ const hasCompleted = this.agentTurns.length > 0;
178
+ if (!hasCompleted && !live) return null;
179
+ let inputTokens = 0;
180
+ let outputTokens = 0;
181
+ let totalTokens = 0;
182
+ let costUsd = 0;
183
+ let stallMs = 0;
184
+ let stallCount = 0;
185
+ let generationMs = 0;
186
+ let ttftMs = 0;
187
+ // sum completed turns
188
+ for (const t of this.agentTurns) {
189
+ inputTokens += t.inputTokens;
190
+ outputTokens += t.outputTokens;
191
+ totalTokens += t.totalTokens;
192
+ costUsd += t.costUsd;
193
+ stallMs += t.stallMs;
194
+ stallCount += t.stallCount;
195
+ generationMs += t.generationMs;
196
+ }
197
+ if (this.agentTurns.length > 0) ttftMs = this.agentTurns[0]!.ttftMs;
198
+ if (live) {
199
+ inputTokens += live.inputTokens;
200
+ outputTokens += live.outputTokens;
201
+ totalTokens += live.totalTokens;
202
+ costUsd += live.costUsd;
203
+ stallMs += live.stallMs;
204
+ stallCount += live.stallCount;
205
+ generationMs += live.generationMs;
206
+ if (ttftMs === 0) ttftMs = live.ttftMs;
207
+ }
208
+ const now = this.now();
209
+ const totalMs = Math.max(0, now - this.agentStartMs);
210
+ const measurementMs =
211
+ outputTokens > 0 && generationMs > 0 ? generationMs : null;
212
+ const tps =
213
+ measurementMs === null
214
+ ? null
215
+ : round(outputTokens / (measurementMs / 1000), 1);
216
+ const validCost = Number.isFinite(costUsd) && costUsd > 0;
217
+ const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
218
+ return {
219
+ tps,
220
+ ttftMs,
221
+ totalMs,
222
+ inputTokens,
223
+ outputTokens,
224
+ stallMs,
225
+ stallCount,
226
+ rateUsdPerMTokens:
227
+ validCost && validTokens
228
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
229
+ : null,
230
+ generationMs,
231
+ totalTokens,
232
+ costUsd: validCost ? costUsd : 0,
233
+ measurementMs,
234
+ };
235
+ }
163
236
  /** Live snapshot while a turn is running — for real-time border refresh. Returns null when idle. */
164
237
  peekLive(): TurnTelemetry | null {
165
238
  const turn = this.turn;
@@ -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