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 +21 -0
- package/dist/chrome-state.js +2 -2
- package/dist/color-policy.js +13 -5
- package/dist/live-border.js +66 -18
- package/dist/run-activity.js +2 -2
- package/dist/telemetry.js +80 -9
- package/package.json +1 -1
- package/src/chrome-state.ts +2 -2
- package/src/color-policy.ts +13 -5
- package/src/live-border.ts +82 -19
- package/src/run-activity.ts +2 -2
- package/src/telemetry.ts +84 -11
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
|
package/dist/chrome-state.js
CHANGED
|
@@ -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 `${
|
|
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?.();
|
package/dist/color-policy.js
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
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
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 "
|
|
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/live-border.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
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
|
}
|
package/dist/run-activity.js
CHANGED
|
@@ -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",
|
|
145
|
+
parts.push(theme.fg("accent", `${snap.turnNumber} turns`));
|
|
146
146
|
}
|
|
147
147
|
else if (snap.phase === "running") {
|
|
148
|
-
parts.push(theme.fg("accent", `
|
|
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
|
-
|
|
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
package/src/chrome-state.ts
CHANGED
|
@@ -91,7 +91,7 @@ export function formatContextBar(
|
|
|
91
91
|
? cacheHitRate
|
|
92
92
|
: 0;
|
|
93
93
|
const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
|
|
94
|
-
return `${
|
|
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?.();
|
package/src/color-policy.ts
CHANGED
|
@@ -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
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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/live-border.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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,
|
package/src/run-activity.ts
CHANGED
|
@@ -186,9 +186,9 @@ export function formatRunActivityTopRight(
|
|
|
186
186
|
|
|
187
187
|
// turn
|
|
188
188
|
if (snap.turnNumber !== undefined) {
|
|
189
|
-
parts.push(theme.fg("accent",
|
|
189
|
+
parts.push(theme.fg("accent", `${snap.turnNumber} turns`));
|
|
190
190
|
} else if (snap.phase === "running") {
|
|
191
|
-
parts.push(theme.fg("accent", `
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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(
|