pi-editor-footer 0.4.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,27 @@ 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
+
15
+ ## [0.5.0] - 2026-08-22
16
+
17
+ ### Changed
18
+
19
+ - Remove duration section positioned to the right of `TTFT` in bottom telemetry (`telemetry:formatTurnTelemetry` now `TPS · TTFT` only, `duration` config ignored)
20
+ - Use `<number> turns` to replace `T<number>` in top run-activity (`run-activity:formatRunActivityTopRight` now `1 turns` / `N turns` instead of `T1`/`T N`)
21
+ - Exchange positions of cache rate section (`c %`) and context bar section (`pct · tokens/window` + bar) in top border — now `c % | pct · tokens/window` instead of `pct · tokens/window | c %` (`chrome-state:formatContextBar`)
22
+
23
+ ### Fixed
24
+
25
+ - Clamp live TPS window and throttle `LiveBorder` for multi-turn runs
26
+ - Add inline `SAFETY` for live-border casts (pi-lens)
27
+
7
28
  ## [0.4.0] - 2026-08-22
8
29
 
9
30
  ### Added
@@ -49,7 +49,7 @@ export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth
49
49
  ? cacheHitRate
50
50
  : 0;
51
51
  const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
52
- return `${base} ${theme.fg("dim", "|")} ${theme.fg(cacheHitColor(rate), cacheText)}`;
52
+ return `${theme.fg(cacheHitColor(rate), cacheText)} ${theme.fg("dim", "|")} ${base}`;
53
53
  }
54
54
  /** Border adapter helper — one call from snapshot + theme/glyphs. */
55
55
  export function formatTopContextFromSnapshot(snapshot, theme, glyphs, isAscii, showIconBar = false) {
@@ -65,7 +65,7 @@ export function formatTopContextFromSnapshot(snapshot, theme, glyphs, isAscii, s
65
65
  */
66
66
  export function createChromeSnapshot(ctx, footerState) {
67
67
  const cwd = ctx?.sessionManager?.getCwd?.() ??
68
- // SAFETY: pi seam
68
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
69
69
  ctx?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
70
70
  process.cwd();
71
71
  const sessionName = ctx?.sessionManager?.getSessionName?.();
@@ -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,16 +13,38 @@
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
+ lastRenderMs = 0;
22
+ pendingRender = null;
21
23
  constructor(deps) {
22
24
  this.deps = deps;
23
25
  }
24
26
  /** Coalesced render: top (run-activity) + bottom (telemetry) + context bar → editor. */
25
27
  render() {
28
+ const now = Date.now();
29
+ if (now - this.lastRenderMs < REFRESH_MS) {
30
+ if (this.pendingRender === null) {
31
+ const delay = REFRESH_MS - (now - this.lastRenderMs);
32
+ this.pendingRender = setTimeout(() => {
33
+ this.pendingRender = null;
34
+ this.lastRenderMs = Date.now();
35
+ this.doRender();
36
+ }, delay);
37
+ // SAFETY: pi TUI seam - timer unref is optional NodeJS API
38
+ const t = this.pendingRender; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
39
+ if (typeof t.unref === "function")
40
+ t.unref();
41
+ }
42
+ return;
43
+ }
44
+ this.lastRenderMs = now;
45
+ this.doRender();
46
+ }
47
+ doRender() {
26
48
  this.refreshTopBorder();
27
49
  this.refreshLiveTelemetry();
28
50
  this.refreshContextBar();
@@ -37,9 +59,7 @@ export class LiveBorder {
37
59
  this.timer = setInterval(() => {
38
60
  try {
39
61
  if (this.deps.runActivityTracker.isRunning()) {
40
- this.refreshTopBorder();
41
- this.refreshLiveTelemetry();
42
- this.refreshContextBar();
62
+ this.render();
43
63
  }
44
64
  else {
45
65
  this.refreshContextBar();
@@ -51,11 +71,15 @@ export class LiveBorder {
51
71
  }, REFRESH_MS);
52
72
  // don't block process exit
53
73
  // SAFETY: pi TUI seam - timer unref is optional NodeJS API
54
- const t = this.timer;
74
+ const t = this.timer; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
55
75
  if (typeof t.unref === "function")
56
76
  t.unref();
57
77
  }
58
78
  stopTick() {
79
+ if (this.pendingRender !== null) {
80
+ clearTimeout(this.pendingRender);
81
+ this.pendingRender = null;
82
+ }
59
83
  if (this.timer !== null) {
60
84
  clearInterval(this.timer);
61
85
  this.timer = null;
@@ -68,13 +92,33 @@ export class LiveBorder {
68
92
  refreshTopBorder() {
69
93
  const editor = this.deps.getEditor();
70
94
  const ctx = this.deps.getCtx();
95
+ const cfg = this.deps.getConfig();
71
96
  if (!editor || !ctx)
72
97
  return;
73
98
  try {
74
- // SAFETY: theme is live pi TUI theme read at render time
75
- const theme = ctx.ui.theme; // 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
76
101
  const snap = this.deps.runActivityTracker.getSnapshot();
77
- 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
+ }
78
122
  editor.setTopRightText(text);
79
123
  }
80
124
  catch {
@@ -98,15 +142,17 @@ export class LiveBorder {
98
142
  return;
99
143
  }
100
144
  try {
101
- // 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
102
146
  const live = this.deps.telemetryTracker.peekLive() ??
103
147
  this.deps.telemetryTracker.getLastTelemetry();
104
148
  if (!live)
105
149
  return;
106
- // SAFETY: pi TUI seam read-only - theme from extension context
150
+ // SAFETY: pi seam intentional unsafe cast, validated at runtime — theme from extension context
107
151
  const theme = ctx?.ui?.theme;
108
152
  const glyphs = resolveGlyphs(cfg.icons.mode);
109
- 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);
110
156
  if (live.totalMs > 0) {
111
157
  editor.setTelemetryText(right);
112
158
  editor.setBottomLeftText("");
@@ -126,10 +172,11 @@ export class LiveBorder {
126
172
  // Deepened via ChromeState: snapshot owns contextUsage + totals derivation behind one seam.
127
173
  // Two adapters (footer + border) now share the same snapshot — proves the seam.
128
174
  // SAFETY: pi seam - snapshot derivation from extension context
129
- const snapshot = createChromeSnapshot(ctx, undefined);
175
+ const snapshot = createChromeSnapshot(ctx, // SAFETY: pi seam — intentional unsafe cast, validated at runtime
176
+ undefined);
130
177
  // SAFETY: pi seam - theme from extension context
131
- const theme = ctx.ui
132
- .theme;
178
+ const theme = ctx.ui // SAFETY: pi seam — intentional unsafe cast, validated at runtime
179
+ .theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
133
180
  const glyphs = resolveGlyphs(cfg.icons.mode);
134
181
  const isAscii = resolveIconMode(cfg.icons.mode) === "ascii";
135
182
  let contextText = "";
@@ -138,15 +185,16 @@ export class LiveBorder {
138
185
  snapshot.contextUsage.contextWindow) {
139
186
  contextText = formatTopContextFromSnapshot(snapshot, theme, glyphs, isAscii,
140
187
  // SAFETY: intentional unsafe cast — validated at runtime
141
- cfg.contextIconBar ??
188
+ cfg.contextIconBar ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
142
189
  false);
143
190
  }
144
191
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
145
192
  let tokensText = "";
146
193
  if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
147
- // SAFETY: pi TUI seam - telemetry tokens for top tokens line
148
- const live = this.deps.telemetryTracker.peekLive() ??
149
- 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();
150
198
  if (live) {
151
199
  tokensText = formatTelemetryTokens(live, theme, cfg.telemetry, glyphs);
152
200
  }
@@ -142,10 +142,10 @@ export function formatRunActivityTopRight(snap, theme, now) {
142
142
  const parts = [];
143
143
  // turn
144
144
  if (snap.turnNumber !== undefined) {
145
- parts.push(theme.fg("accent", `T${snap.turnNumber}`));
145
+ parts.push(theme.fg("accent", `${snap.turnNumber} turns`));
146
146
  }
147
147
  else if (snap.phase === "running") {
148
- parts.push(theme.fg("accent", `T1`));
148
+ parts.push(theme.fg("accent", `1 turns`));
149
149
  }
150
150
  // duration
151
151
  const dur = snap.durationMs ??
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;
@@ -125,7 +198,8 @@ export class TurnTelemetryTracker {
125
198
  outputTokens = turn.liveEstimatedTokens;
126
199
  totalTokens = Math.max(totalTokens, inputTokens + outputTokens);
127
200
  }
128
- const measurementMs = genMs > 0 && outputTokens > 0 ? genMs : null;
201
+ // Require minimum window to avoid spike on tiny genMs (multi-turn fast second turn)
202
+ const measurementMs = genMs >= 500 && outputTokens > 0 ? genMs : null;
129
203
  const tps = measurementMs === null
130
204
  ? null
131
205
  : round(outputTokens / (measurementMs / 1000), 1);
@@ -268,8 +342,10 @@ export class TurnTelemetryTracker {
268
342
  const telemetry = this.endTurn();
269
343
  if (telemetry && this.agentStartMs !== null)
270
344
  this.agentTurns.push(telemetry);
271
- if (telemetry)
345
+ if (telemetry) {
346
+ this.lastTurnTelemetry = telemetry;
272
347
  this.lastTelemetry = telemetry;
348
+ }
273
349
  return telemetry;
274
350
  }
275
351
  endTurn() {
@@ -350,10 +426,11 @@ export class TurnTelemetryTracker {
350
426
  measurementMs,
351
427
  };
352
428
  this.lastTelemetry = result;
429
+ // keep lastTurnTelemetry as last turn's per-turn telemetry (live input stays per-turn, not agent total)
353
430
  return result;
354
431
  }
355
432
  }
356
- function formatTurnDuration(ms) {
433
+ export function formatTurnDuration(ms) {
357
434
  if (ms < 60_000) {
358
435
  // TTFT fixed width: 2-digit integer + one decimal -> padded 4 + "s" =5, then overall duration field padded to 7 for telemetry totalMs
359
436
  // For TTFT we want 5, for duration we want 7 — caller will pad accordingly, so here return 5 for <60s case
@@ -382,12 +459,6 @@ export function formatTurnTelemetry(telemetry, theme, config, glyphs) {
382
459
  const padded = sec.padStart(4, " ");
383
460
  parts.push(theme.fg("text", `${padded}s TTFT`));
384
461
  }
385
- if (config.duration) {
386
- // duration fixed width 7 (e.g. " 15m 31s" / " 24h 21m" / " 5.0s")
387
- const raw = formatTurnDuration(telemetry.totalMs);
388
- const padded = raw.padStart(7, " ");
389
- parts.push(theme.fg("success", `${padded}`));
390
- }
391
462
  if (config.stalls && telemetry.stallMs > 0) {
392
463
  parts.push(theme.fg("warning", `${g.stall}${telemetry.stallCount}×${formatTurnDuration(telemetry.stallMs).trim()}`));
393
464
  }
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.4.0",
21
+ "version": "0.6.0",
22
22
  "main": "./dist/index.js",
23
23
  "files": [
24
24
  "dist",
@@ -91,7 +91,7 @@ export function formatContextBar(
91
91
  ? cacheHitRate
92
92
  : 0;
93
93
  const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
94
- return `${base} ${theme.fg("dim", "|")} ${theme.fg(cacheHitColor(rate), cacheText)}`;
94
+ return `${theme.fg(cacheHitColor(rate), cacheText)} ${theme.fg("dim", "|")} ${base}`;
95
95
  }
96
96
 
97
97
  /** Border adapter helper — one call from snapshot + theme/glyphs. */
@@ -139,7 +139,7 @@ export function createChromeSnapshot(
139
139
  ): ChromeSnapshot {
140
140
  const cwd =
141
141
  ctx?.sessionManager?.getCwd?.() ??
142
- // SAFETY: pi seam
142
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
143
143
  (ctx as unknown as { cwd?: string })?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
144
144
  process.cwd();
145
145
  const sessionName = ctx?.sessionManager?.getSessionName?.();
@@ -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";
@@ -37,11 +41,33 @@ export interface LiveBorderDeps {
37
41
 
38
42
  export class LiveBorder {
39
43
  private timer: ReturnType<typeof setInterval> | null = null;
44
+ private lastRenderMs = 0;
45
+ private pendingRender: ReturnType<typeof setTimeout> | null = null;
40
46
 
41
47
  constructor(private readonly deps: LiveBorderDeps) {}
42
48
 
43
49
  /** Coalesced render: top (run-activity) + bottom (telemetry) + context bar → editor. */
44
50
  render(): void {
51
+ const now = Date.now();
52
+ if (now - this.lastRenderMs < REFRESH_MS) {
53
+ if (this.pendingRender === null) {
54
+ const delay = REFRESH_MS - (now - this.lastRenderMs);
55
+ this.pendingRender = setTimeout(() => {
56
+ this.pendingRender = null;
57
+ this.lastRenderMs = Date.now();
58
+ this.doRender();
59
+ }, delay);
60
+ // SAFETY: pi TUI seam - timer unref is optional NodeJS API
61
+ const t = this.pendingRender as unknown as { unref?: () => void }; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
62
+ if (typeof t.unref === "function") t.unref();
63
+ }
64
+ return;
65
+ }
66
+ this.lastRenderMs = now;
67
+ this.doRender();
68
+ }
69
+
70
+ private doRender(): void {
45
71
  this.refreshTopBorder();
46
72
  this.refreshLiveTelemetry();
47
73
  this.refreshContextBar();
@@ -57,9 +83,7 @@ export class LiveBorder {
57
83
  this.timer = setInterval(() => {
58
84
  try {
59
85
  if (this.deps.runActivityTracker.isRunning()) {
60
- this.refreshTopBorder();
61
- this.refreshLiveTelemetry();
62
- this.refreshContextBar();
86
+ this.render();
63
87
  } else {
64
88
  this.refreshContextBar();
65
89
  }
@@ -69,11 +93,15 @@ export class LiveBorder {
69
93
  }, REFRESH_MS);
70
94
  // don't block process exit
71
95
  // SAFETY: pi TUI seam - timer unref is optional NodeJS API
72
- const t = this.timer as unknown as { unref?: () => void };
96
+ const t = this.timer as unknown as { unref?: () => void }; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
73
97
  if (typeof t.unref === "function") t.unref();
74
98
  }
75
99
 
76
100
  stopTick(): void {
101
+ if (this.pendingRender !== null) {
102
+ clearTimeout(this.pendingRender);
103
+ this.pendingRender = null;
104
+ }
77
105
  if (this.timer !== null) {
78
106
  clearInterval(this.timer);
79
107
  this.timer = null;
@@ -89,14 +117,43 @@ export class LiveBorder {
89
117
  private refreshTopBorder(): void {
90
118
  const editor = this.deps.getEditor();
91
119
  const ctx = this.deps.getCtx();
120
+ const cfg = this.deps.getConfig();
92
121
  if (!editor || !ctx) return;
93
122
  try {
94
- // 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
95
124
  const theme = (
96
125
  ctx.ui as unknown as { theme: { fg(s: string, t: string): string } }
97
- ).theme; // SAFETY: pi seam
126
+ ).theme; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
98
127
  const snap = this.deps.runActivityTracker.getSnapshot();
99
- 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
+ }
100
157
  editor.setTopRightText(text);
101
158
  } catch {
102
159
  // SAFETY: best-effort UI, ignore recoverable error
@@ -118,18 +175,20 @@ export class LiveBorder {
118
175
  return;
119
176
  }
120
177
  try {
121
- // 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
122
179
  const live =
123
180
  this.deps.telemetryTracker.peekLive() ??
124
181
  this.deps.telemetryTracker.getLastTelemetry();
125
182
  if (!live) return;
126
- // SAFETY: pi TUI seam read-only - theme from extension context
183
+ // SAFETY: pi seam intentional unsafe cast, validated at runtime — theme from extension context
127
184
  const theme = (ctx as unknown as { ui?: { theme?: unknown } })?.ui?.theme;
128
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 };
129
188
  const right = formatTurnTelemetry(
130
189
  live,
131
190
  theme as never,
132
- cfg.telemetry,
191
+ bottomCfg as never,
133
192
  glyphs as never,
134
193
  );
135
194
  if (live.totalMs > 0) {
@@ -151,12 +210,12 @@ export class LiveBorder {
151
210
  // Two adapters (footer + border) now share the same snapshot — proves the seam.
152
211
  // SAFETY: pi seam - snapshot derivation from extension context
153
212
  const snapshot = createChromeSnapshot(
154
- ctx as unknown as Parameters<typeof createChromeSnapshot>[0],
213
+ ctx as unknown as Parameters<typeof createChromeSnapshot>[0], // SAFETY: pi seam — intentional unsafe cast, validated at runtime
155
214
  undefined,
156
215
  );
157
216
  // SAFETY: pi seam - theme from extension context
158
- const theme = (ctx as unknown as { ui: { theme: unknown } }).ui
159
- .theme as unknown as never;
217
+ const theme = (ctx as unknown as { ui: { theme: unknown } }).ui // SAFETY: pi seam — intentional unsafe cast, validated at runtime
218
+ .theme as unknown as never; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
160
219
  const glyphs = resolveGlyphs(cfg.icons.mode);
161
220
  const isAscii = resolveIconMode(cfg.icons.mode) === "ascii";
162
221
  let contextText = "";
@@ -171,17 +230,21 @@ export class LiveBorder {
171
230
  glyphs,
172
231
  isAscii,
173
232
  // SAFETY: intentional unsafe cast — validated at runtime
174
- (cfg as unknown as { contextIconBar?: boolean }).contextIconBar ??
233
+ (cfg as unknown as { contextIconBar?: boolean }).contextIconBar ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
175
234
  false,
176
235
  );
177
236
  }
178
237
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
179
238
  let tokensText = "";
180
239
  if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
181
- // SAFETY: pi TUI seam - telemetry tokens for top tokens line
182
- const live =
183
- this.deps.telemetryTracker.peekLive() ??
184
- 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();
185
248
  if (live) {
186
249
  tokensText = formatTelemetryTokens(
187
250
  live,
@@ -186,9 +186,9 @@ export function formatRunActivityTopRight(
186
186
 
187
187
  // turn
188
188
  if (snap.turnNumber !== undefined) {
189
- parts.push(theme.fg("accent", `T${snap.turnNumber}`));
189
+ parts.push(theme.fg("accent", `${snap.turnNumber} turns`));
190
190
  } else if (snap.phase === "running") {
191
- parts.push(theme.fg("accent", `T1`));
191
+ parts.push(theme.fg("accent", `1 turns`));
192
192
  }
193
193
 
194
194
  // duration
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;
@@ -176,7 +249,8 @@ export class TurnTelemetryTracker {
176
249
  decayedTps = null;
177
250
  } else {
178
251
  // exponential decay, half-life ~5s (exp(-elapsed/7200) => half at ~5s ln2*7200≈5000)
179
- const decayed = this.decayBaseTps * Math.exp(-elapsedSinceDecay / 7200);
252
+ const decayed =
253
+ this.decayBaseTps * Math.exp(-elapsedSinceDecay / 7200);
180
254
  if (decayed >= 0.05) decayedTps = round(decayed, 1);
181
255
  }
182
256
  }
@@ -225,7 +299,8 @@ export class TurnTelemetryTracker {
225
299
  outputTokens = turn.liveEstimatedTokens;
226
300
  totalTokens = Math.max(totalTokens, inputTokens + outputTokens);
227
301
  }
228
- const measurementMs = genMs > 0 && outputTokens > 0 ? genMs : null;
302
+ // Require minimum window to avoid spike on tiny genMs (multi-turn fast second turn)
303
+ const measurementMs = genMs >= 500 && outputTokens > 0 ? genMs : null;
229
304
  const tps =
230
305
  measurementMs === null
231
306
  ? null
@@ -384,7 +459,10 @@ export class TurnTelemetryTracker {
384
459
  const telemetry = this.endTurn();
385
460
  if (telemetry && this.agentStartMs !== null)
386
461
  this.agentTurns.push(telemetry);
387
- if (telemetry) this.lastTelemetry = telemetry;
462
+ if (telemetry) {
463
+ this.lastTurnTelemetry = telemetry;
464
+ this.lastTelemetry = telemetry;
465
+ }
388
466
  return telemetry;
389
467
  }
390
468
 
@@ -476,11 +554,12 @@ export class TurnTelemetryTracker {
476
554
  measurementMs,
477
555
  };
478
556
  this.lastTelemetry = result;
557
+ // keep lastTurnTelemetry as last turn's per-turn telemetry (live input stays per-turn, not agent total)
479
558
  return result;
480
559
  }
481
560
  }
482
561
 
483
- function formatTurnDuration(ms: number): string {
562
+ export function formatTurnDuration(ms: number): string {
484
563
  if (ms < 60_000) {
485
564
  // TTFT fixed width: 2-digit integer + one decimal -> padded 4 + "s" =5, then overall duration field padded to 7 for telemetry totalMs
486
565
  // For TTFT we want 5, for duration we want 7 — caller will pad accordingly, so here return 5 for <60s case
@@ -533,12 +612,6 @@ export function formatTurnTelemetry(
533
612
  const padded = sec.padStart(4, " ");
534
613
  parts.push(theme.fg("text", `${padded}s TTFT`));
535
614
  }
536
- if (config.duration) {
537
- // duration fixed width 7 (e.g. " 15m 31s" / " 24h 21m" / " 5.0s")
538
- const raw = formatTurnDuration(telemetry.totalMs);
539
- const padded = raw.padStart(7, " ");
540
- parts.push(theme.fg("success", `${padded}`));
541
- }
542
615
  if (config.stalls && telemetry.stallMs > 0) {
543
616
  parts.push(
544
617
  theme.fg(