pi-open-tui 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -12,8 +12,9 @@ A polished TUI for [Pi](https://pi.dev) coding agent. Combines the best of pi-ha
12
12
  - **60+ runtime detection** — Node, Rust, Go, Python, Ruby, Java, Swift, Kotlin, C/C++, Deno, Bun, and many more
13
13
  - **Git status** — branch, ahead/behind, modified/untracked/staged/stashed, detached HEAD commit hash + tag
14
14
  - **Working timer** — live elapsed time while the agent is working, done duration when finished
15
+ - **Turn telemetry** — generation speed, TTFT, stalls, tokens, and list-price rate after each complete agent run
15
16
  - **Zero prototype patches** — uses public Pi APIs (setHeader/setFooter/setEditorComponent), safe across Pi updates
16
- - **Interactive settings UI** — `/open-tui` opens a tabbed settings dialog (Features / Icons / Segments)
17
+ - **Interactive settings UI** — `/open-tui` opens a tabbed settings dialog (Features / Icons / Segments / Telemetry)
17
18
 
18
19
  ## Install
19
20
 
@@ -46,6 +47,15 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
46
47
  "context": true,
47
48
  "tokens": true,
48
49
  "cost": true
50
+ },
51
+ "telemetry": {
52
+ "enabled": true,
53
+ "tps": true,
54
+ "ttft": true,
55
+ "duration": true,
56
+ "tokens": true,
57
+ "stalls": true,
58
+ "cost": true
49
59
  }
50
60
  }
51
61
  ```
@@ -53,6 +63,18 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
53
63
  - `icons.mode`: `auto` (detect Nerd Font), `nerd` (force Nerd Font glyphs), or `ascii` (plain fallbacks)
54
64
  - `footerSegments.gitCommit`: shows short hash + tag on detached HEAD (off by default)
55
65
 
66
+ ## Turn telemetry
67
+
68
+ After each complete agent run, open-tui shows one transient notification. Tool-call turns are aggregated into that single result:
69
+
70
+ ```text
71
+ > TPS 42.5 tok/s | ~ TTFT 1.2s | + 29.7s | ↓ in 567 | ↑ out 1.2k | ! stall 1x / 4.3s | $ $3.60/M
72
+ ```
73
+
74
+ The notification uses the footer's icon mode and semantic theme colors. Configure its master switch and individual TPS, TTFT, duration, token, stall, and cost segments from the **Telemetry** tab in `/open-tui`.
75
+
76
+ TPS excludes time-to-first-token, detected stalls, and tool execution between messages. In a multi-turn agent run, it is weighted from the turns with measurable streaming spans; an unmeasurable tool turn no longer hides a measurable final response. A rate that cannot be identified reliably is shown as `TPS —`. The `stall` segment shows occurrence count followed by accumulated duration. The optional `$ / M` value uses the model's list-price `usage.cost.total`; it is not the session's cumulative cost shown in the footer.
77
+
56
78
  ## Local development
57
79
 
58
80
  ```bash
@@ -70,5 +92,6 @@ This project builds on the work of several Pi community packages:
70
92
  - **[pi-haiku](https://github.com/nnocte/pi-haiku)** — the 2-line footer structure (location+model · timer+context) and working-timer pattern
71
93
  - **[pi-claude-code-tui](https://github.com/Phoobobo/pi-claude-code-tui)** — the 16-frame animated Pi logo and rounded editor border technique
72
94
  - **[pi-zentui](https://github.com/lmilojevicc/pi-zentui)** — the Starship-style footer segments (git status icons, runtime detection, context gauge), generation-based session lifecycle, and interactive settings UI pattern
95
+ - **[pi-tps](https://github.com/monotykamary/pi-tps)** — the turn timing, stall detection, and conservative TPS measurement approach
73
96
 
74
97
  The animated logo frames are derived from `pi-claude-code-tui`, which in turn derive from Pi's official install script (`pi.dev/install.sh`). The runtime detection list and git porcelain parsing borrow structure from `pi-zentui`.
@@ -16,12 +16,23 @@ export interface FooterSegments {
16
16
  cost: boolean;
17
17
  }
18
18
 
19
+ export interface TelemetryConfig {
20
+ enabled: boolean;
21
+ tps: boolean;
22
+ ttft: boolean;
23
+ duration: boolean;
24
+ tokens: boolean;
25
+ stalls: boolean;
26
+ cost: boolean;
27
+ }
28
+
19
29
  export interface OpenTuiConfig {
20
30
  enabled: boolean;
21
31
  icons: {
22
32
  mode: IconMode;
23
33
  };
24
34
  footerSegments: FooterSegments;
35
+ telemetry: TelemetryConfig;
25
36
  }
26
37
 
27
38
  export const DEFAULT_CONFIG: OpenTuiConfig = {
@@ -39,6 +50,15 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
39
50
  tokens: true,
40
51
  cost: true,
41
52
  },
53
+ telemetry: {
54
+ enabled: true,
55
+ tps: true,
56
+ ttft: true,
57
+ duration: true,
58
+ tokens: true,
59
+ stalls: true,
60
+ cost: true,
61
+ },
42
62
  };
43
63
 
44
64
  export function getConfigPath(): string {
@@ -12,6 +12,9 @@ export interface IconGlyphs {
12
12
  output: string;
13
13
  cacheHit: string;
14
14
  cost: string;
15
+ speed: string;
16
+ latency: string;
17
+ stall: string;
15
18
  extensions: string;
16
19
  ahead: string;
17
20
  behind: string;
@@ -37,6 +40,9 @@ const NERD_GLYPHS: IconGlyphs = {
37
40
  output: "",
38
41
  cacheHit: "",
39
42
  cost: "",
43
+ speed: "󰓅",
44
+ latency: "",
45
+ stall: "",
40
46
  extensions: "",
41
47
  ahead: "↑",
42
48
  behind: "↓",
@@ -61,10 +67,13 @@ const ASCII_GLYPHS: IconGlyphs = {
61
67
  context: "%",
62
68
  model: "M",
63
69
  thinking: "~",
64
- input: "",
65
- output: "",
70
+ input: "",
71
+ output: "",
66
72
  cacheHit: "c",
67
73
  cost: "$",
74
+ speed: ">",
75
+ latency: "~",
76
+ stall: "!",
68
77
  extensions: "&",
69
78
  ahead: "^",
70
79
  behind: "v",
@@ -7,6 +7,7 @@ import { readGitStatus } from "./git.ts";
7
7
  import { readRuntimeInfo } from "./runtime.ts";
8
8
  import { SessionLifecycle } from "./session-lifecycle.ts";
9
9
  import { registerSettingsCommand } from "./settings-command.ts";
10
+ import { formatTurnTelemetry, TurnTelemetryTracker } from "./telemetry.ts";
10
11
  import {
11
12
  createInitialState,
12
13
  getModelMeta,
@@ -43,6 +44,7 @@ function isTuiContext(ctx: ExtensionContext): boolean {
43
44
  export default function (pi: ExtensionAPI) {
44
45
  const sessionLifecycle = new SessionLifecycle();
45
46
  const state: FooterState = createInitialState();
47
+ const turnTelemetry = new TurnTelemetryTracker();
46
48
 
47
49
  let config: OpenTuiConfig = structuredClone(DEFAULT_CONFIG);
48
50
  let active = false;
@@ -175,7 +177,8 @@ export default function (pi: ExtensionAPI) {
175
177
  lastCtx = undefined;
176
178
  });
177
179
 
178
- pi.on("agent_start", (_event, _ctx) => {
180
+ pi.on("agent_start", (event, _ctx) => {
181
+ turnTelemetry.handle(event);
179
182
  if (!sessionLifecycle.isCurrent()) return;
180
183
  state.workingSince = Date.now();
181
184
  state.lastDoneIn = undefined;
@@ -192,6 +195,34 @@ export default function (pi: ExtensionAPI) {
192
195
  requestFooterRender?.();
193
196
  });
194
197
 
198
+ pi.on("turn_start", (event) => {
199
+ turnTelemetry.handle(event);
200
+ });
201
+
202
+ pi.on("message_start", (event) => {
203
+ turnTelemetry.handle(event);
204
+ });
205
+
206
+ pi.on("message_update", (event) => {
207
+ turnTelemetry.handle(event);
208
+ });
209
+
210
+ pi.on("tool_execution_start", (event) => {
211
+ turnTelemetry.handle(event);
212
+ });
213
+
214
+ pi.on("turn_end", (event) => {
215
+ turnTelemetry.handle(event);
216
+ });
217
+
218
+ pi.on("agent_settled", (event, ctx) => {
219
+ const telemetry = turnTelemetry.handle(event);
220
+ if (telemetry && config.enabled && config.telemetry.enabled && isTuiContext(ctx)) {
221
+ const message = formatTurnTelemetry(telemetry, ctx.ui.theme, config.telemetry, config.icons.mode);
222
+ if (message) ctx.ui.notify(message, "info");
223
+ }
224
+ });
225
+
195
226
  pi.on("model_select", (_event, ctx) => {
196
227
  refreshInteractiveState(ctx);
197
228
  });
@@ -200,7 +231,8 @@ export default function (pi: ExtensionAPI) {
200
231
  refreshInteractiveState(ctx);
201
232
  });
202
233
 
203
- pi.on("message_end", (_event, ctx) => {
234
+ pi.on("message_end", (event, ctx) => {
235
+ turnTelemetry.handle(event);
204
236
  if (!sessionLifecycle.isCurrent()) return;
205
237
  invalidateUsageCache();
206
238
  refreshInteractiveState(ctx);
@@ -17,12 +17,13 @@ interface SettingItem {
17
17
  values: string[];
18
18
  }
19
19
 
20
- type Tab = "features" | "icons" | "segments";
20
+ type Tab = "features" | "icons" | "segments" | "telemetry";
21
21
 
22
22
  const TABS: { id: Tab; label: string }[] = [
23
23
  { id: "features", label: "Features" },
24
24
  { id: "icons", label: "Icons" },
25
25
  { id: "segments", label: "Segments" },
26
+ { id: "telemetry", label: "Telemetry" },
26
27
  ];
27
28
 
28
29
  function toggleSetting(config: OpenTuiConfig, key: keyof OpenTuiConfig["footerSegments"]): OpenTuiConfig {
@@ -46,6 +47,13 @@ function toggleEnabled(config: OpenTuiConfig): OpenTuiConfig {
46
47
  return { ...config, enabled: !config.enabled };
47
48
  }
48
49
 
50
+ function toggleTelemetry(config: OpenTuiConfig, key: keyof OpenTuiConfig["telemetry"]): OpenTuiConfig {
51
+ return {
52
+ ...config,
53
+ telemetry: { ...config.telemetry, [key]: !config.telemetry[key] },
54
+ };
55
+ }
56
+
49
57
  function buildFeaturesItems(config: OpenTuiConfig): SettingItem[] {
50
58
  return [
51
59
  {
@@ -82,11 +90,25 @@ function buildSegmentsItems(config: OpenTuiConfig): SettingItem[] {
82
90
  ];
83
91
  }
84
92
 
93
+ function buildTelemetryItems(config: OpenTuiConfig): SettingItem[] {
94
+ const telemetry = config.telemetry;
95
+ return [
96
+ { id: "enabled", label: "Enabled", currentValue: telemetry.enabled ? "on" : "off", values: ["on", "off"] },
97
+ { id: "tps", label: "TPS", currentValue: telemetry.tps ? "on" : "off", values: ["on", "off"] },
98
+ { id: "ttft", label: "TTFT", currentValue: telemetry.ttft ? "on" : "off", values: ["on", "off"] },
99
+ { id: "duration", label: "total duration", currentValue: telemetry.duration ? "on" : "off", values: ["on", "off"] },
100
+ { id: "tokens", label: "token counts", currentValue: telemetry.tokens ? "on" : "off", values: ["on", "off"] },
101
+ { id: "stalls", label: "stall details", currentValue: telemetry.stalls ? "on" : "off", values: ["on", "off"] },
102
+ { id: "cost", label: "cost rate", currentValue: telemetry.cost ? "on" : "off", values: ["on", "off"] },
103
+ ];
104
+ }
105
+
85
106
  function buildItems(tab: Tab, config: OpenTuiConfig): SettingItem[] {
86
107
  switch (tab) {
87
108
  case "features": return buildFeaturesItems(config);
88
109
  case "icons": return buildIconsItems(config);
89
110
  case "segments": return buildSegmentsItems(config);
111
+ case "telemetry": return buildTelemetryItems(config);
90
112
  }
91
113
  }
92
114
 
@@ -104,6 +126,9 @@ function handleSettingChange(
104
126
  if (tab === "segments") {
105
127
  return toggleSetting(config, itemId as keyof OpenTuiConfig["footerSegments"]);
106
128
  }
129
+ if (tab === "telemetry") {
130
+ return toggleTelemetry(config, itemId as keyof OpenTuiConfig["telemetry"]);
131
+ }
107
132
  return config;
108
133
  }
109
134
 
@@ -0,0 +1,375 @@
1
+ import type {
2
+ AgentSettledEvent,
3
+ AgentStartEvent,
4
+ MessageEndEvent,
5
+ MessageStartEvent,
6
+ MessageUpdateEvent,
7
+ ToolExecutionStartEvent,
8
+ TurnEndEvent,
9
+ TurnStartEvent,
10
+ Theme,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import type { IconMode, TelemetryConfig } from "./config.ts";
13
+ import { resolveGlyphs } from "./icons.ts";
14
+ import { fmtTokens, formatDuration } from "./utils.ts";
15
+
16
+ const STALL_THRESHOLD_MS = 500;
17
+ const MIN_STREAM_MS = 1;
18
+ const MIN_STREAM_UPDATES = 5;
19
+ const MIN_INTER_CHUNK_MS = 1;
20
+ const MIN_GENERATION_MS = 200;
21
+ const STALL_DOMINANCE_RATIO = 0.85;
22
+ const MAX_PLAUSIBLE_TPS = 10_000;
23
+
24
+ type TelemetryEvent =
25
+ | AgentStartEvent
26
+ | AgentSettledEvent
27
+ | TurnStartEvent
28
+ | MessageStartEvent
29
+ | MessageUpdateEvent
30
+ | MessageEndEvent
31
+ | ToolExecutionStartEvent
32
+ | TurnEndEvent;
33
+ type AgentMessage = MessageStartEvent["message"];
34
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
35
+
36
+ interface MessageTiming {
37
+ startMs: number;
38
+ lastUpdateMs: number;
39
+ firstUpdateSeen: boolean;
40
+ streamUpdateCount: number;
41
+ firstStreamUpdateMs: number | null;
42
+ lastStreamUpdateMs: number | null;
43
+ inStall: boolean;
44
+ }
45
+
46
+ interface TurnTiming {
47
+ startMs: number;
48
+ firstTokenMs: number | null;
49
+ currentMessage: MessageTiming | null;
50
+ messages: AssistantMessage[];
51
+ generationMs: number;
52
+ streamMs: number;
53
+ streamIntervals: number;
54
+ streamUpdateCount: number;
55
+ stallMs: number;
56
+ stallCount: number;
57
+ isToolCall: boolean;
58
+ modelKey: string | null;
59
+ }
60
+
61
+ export interface TurnTelemetry {
62
+ tps: number | null;
63
+ ttftMs: number;
64
+ totalMs: number;
65
+ inputTokens: number;
66
+ outputTokens: number;
67
+ stallMs: number;
68
+ stallCount: number;
69
+ rateUsdPerMTokens: number | null;
70
+ generationMs: number;
71
+ totalTokens: number;
72
+ costUsd: number;
73
+ measurementMs: number | null;
74
+ }
75
+
76
+ function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
77
+ return message.role === "assistant";
78
+ }
79
+
80
+ function round(value: number, decimals: number): number {
81
+ const factor = 10 ** decimals;
82
+ return Math.round(value * factor) / factor;
83
+ }
84
+
85
+ export class TurnTelemetryTracker {
86
+ private readonly now: () => number;
87
+ private readonly tpsCaps = new Map<string, number>();
88
+ private turn: TurnTiming | undefined;
89
+ private agentStartMs: number | null = null;
90
+ private agentTurns: TurnTelemetry[] = [];
91
+
92
+ constructor(now: () => number = () => performance.now()) {
93
+ this.now = now;
94
+ }
95
+
96
+ handle(event: TelemetryEvent): TurnTelemetry | undefined {
97
+ switch (event.type) {
98
+ case "agent_start":
99
+ if (this.agentStartMs === null) {
100
+ this.agentStartMs = this.now();
101
+ this.agentTurns = [];
102
+ }
103
+ return;
104
+ case "agent_settled":
105
+ return this.endAgent();
106
+ case "turn_start":
107
+ this.startTurn();
108
+ return;
109
+ case "message_start":
110
+ this.startMessage(event.message);
111
+ return;
112
+ case "message_update":
113
+ this.updateMessage(event);
114
+ return;
115
+ case "message_end":
116
+ this.endMessage(event.message);
117
+ return;
118
+ case "tool_execution_start":
119
+ if (this.turn) this.turn.isToolCall = true;
120
+ return;
121
+ case "turn_end":
122
+ return this.endTurnAndCollect();
123
+ }
124
+ }
125
+
126
+ private startTurn(): void {
127
+ this.turn = {
128
+ startMs: this.now(),
129
+ firstTokenMs: null,
130
+ currentMessage: null,
131
+ messages: [],
132
+ generationMs: 0,
133
+ streamMs: 0,
134
+ streamIntervals: 0,
135
+ streamUpdateCount: 0,
136
+ stallMs: 0,
137
+ stallCount: 0,
138
+ isToolCall: false,
139
+ modelKey: null,
140
+ };
141
+ }
142
+
143
+ private startMessage(message: AgentMessage): void {
144
+ if (!this.turn || !isAssistantMessage(message)) return;
145
+ const now = this.now();
146
+ this.turn.currentMessage = {
147
+ startMs: now,
148
+ lastUpdateMs: now,
149
+ firstUpdateSeen: false,
150
+ streamUpdateCount: 0,
151
+ firstStreamUpdateMs: null,
152
+ lastStreamUpdateMs: null,
153
+ inStall: false,
154
+ };
155
+ this.turn.modelKey ??= `${message.provider}:${message.model}`;
156
+ }
157
+
158
+ private updateMessage(event: MessageUpdateEvent): void {
159
+ const turn = this.turn;
160
+ const current = turn?.currentMessage;
161
+ const streamEvent = event.assistantMessageEvent;
162
+ if (
163
+ streamEvent.type !== "text_delta" &&
164
+ streamEvent.type !== "thinking_delta" &&
165
+ streamEvent.type !== "toolcall_delta"
166
+ ) return;
167
+ if (streamEvent.delta.length === 0) return;
168
+ const message = event.message;
169
+ if (!turn || !current || !isAssistantMessage(message)) return;
170
+
171
+ const now = this.now();
172
+ if (!current.firstUpdateSeen) {
173
+ current.firstUpdateSeen = true;
174
+ turn.firstTokenMs ??= now;
175
+ current.lastUpdateMs = now;
176
+ return;
177
+ }
178
+
179
+ current.streamUpdateCount++;
180
+ current.firstStreamUpdateMs ??= now;
181
+ current.lastStreamUpdateMs = now;
182
+
183
+ const gap = now - current.lastUpdateMs;
184
+ if (gap >= STALL_THRESHOLD_MS) {
185
+ if (!current.inStall) turn.stallCount++;
186
+ current.inStall = true;
187
+ turn.stallMs += gap;
188
+ } else {
189
+ current.inStall = false;
190
+ }
191
+ current.lastUpdateMs = now;
192
+ }
193
+
194
+ private endMessage(message: AgentMessage): void {
195
+ const turn = this.turn;
196
+ if (!turn || !isAssistantMessage(message)) return;
197
+
198
+ const current = turn.currentMessage;
199
+ if (current) {
200
+ turn.generationMs += this.now() - current.startMs;
201
+ turn.streamUpdateCount += current.streamUpdateCount;
202
+ turn.streamIntervals += Math.max(0, current.streamUpdateCount - 1);
203
+ // Sum per-message spans so tool execution between messages never enters TPS.
204
+ if (current.firstStreamUpdateMs !== null && current.lastStreamUpdateMs !== null) {
205
+ turn.streamMs += current.lastStreamUpdateMs - current.firstStreamUpdateMs;
206
+ }
207
+ turn.currentMessage = null;
208
+ }
209
+ turn.messages.push(message);
210
+ turn.modelKey ??= `${message.provider}:${message.model}`;
211
+ }
212
+
213
+ private endTurnAndCollect(): TurnTelemetry | undefined {
214
+ const telemetry = this.endTurn();
215
+ if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
216
+ return telemetry;
217
+ }
218
+
219
+ private endTurn(): TurnTelemetry | undefined {
220
+ const turn = this.turn;
221
+ this.turn = undefined;
222
+ if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
223
+
224
+ const endMs = this.now();
225
+ let inputTokens = 0;
226
+ let outputTokens = 0;
227
+ let totalTokens = 0;
228
+ let costUsd = 0;
229
+ for (const message of turn.messages) {
230
+ inputTokens += message.usage.input;
231
+ outputTokens += message.usage.output;
232
+ totalTokens += message.usage.totalTokens;
233
+ costUsd += message.usage.cost.total;
234
+ }
235
+ if (![inputTokens, outputTokens, totalTokens, costUsd].every(Number.isFinite)) {
236
+ throw new Error("Invalid assistant usage in turn telemetry");
237
+ }
238
+ if (outputTokens <= 0 || !turn.modelKey) return;
239
+
240
+ const streamMs = turn.streamUpdateCount > 0 ? turn.streamMs : null;
241
+ const averageGapMs = turn.streamIntervals > 0 ? turn.streamMs / turn.streamIntervals : 0;
242
+ let tps: number | null = null;
243
+ let measurementMs: number | null = null;
244
+ let primary = false;
245
+ if (
246
+ streamMs !== null &&
247
+ streamMs >= MIN_STREAM_MS &&
248
+ turn.streamUpdateCount >= MIN_STREAM_UPDATES &&
249
+ averageGapMs >= MIN_INTER_CHUNK_MS &&
250
+ turn.stallMs < streamMs &&
251
+ streamMs - turn.stallMs >= MIN_GENERATION_MS &&
252
+ turn.stallMs < streamMs - turn.stallMs
253
+ ) {
254
+ measurementMs = streamMs - turn.stallMs;
255
+ tps = round(outputTokens / (measurementMs / 1000), 1);
256
+ primary = true;
257
+ } else if (turn.streamUpdateCount >= 2 && turn.generationMs >= MIN_GENERATION_MS) {
258
+ let activeMs = turn.generationMs - turn.stallMs;
259
+ if (activeMs < MIN_GENERATION_MS || turn.stallMs > turn.generationMs * STALL_DOMINANCE_RATIO) {
260
+ activeMs = turn.generationMs - turn.stallMs / 2;
261
+ }
262
+ measurementMs = Math.max(activeMs, MIN_GENERATION_MS);
263
+ tps = round(outputTokens / (measurementMs / 1000), 1);
264
+ }
265
+
266
+ if (tps !== null && tps > MAX_PLAUSIBLE_TPS) {
267
+ tps = null;
268
+ measurementMs = null;
269
+ primary = false;
270
+ }
271
+ if (primary && tps !== null) {
272
+ this.tpsCaps.set(turn.modelKey, Math.max(tps, this.tpsCaps.get(turn.modelKey) ?? 0));
273
+ }
274
+ if (turn.isToolCall && tps !== null) {
275
+ tps = this.tpsCaps.has(turn.modelKey)
276
+ ? Math.min(tps, this.tpsCaps.get(turn.modelKey)!)
277
+ : null;
278
+ if (tps === null) measurementMs = null;
279
+ }
280
+
281
+ const validCost = Number.isFinite(costUsd) && costUsd > 0;
282
+ const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
283
+ return {
284
+ tps,
285
+ ttftMs: turn.firstTokenMs - turn.startMs,
286
+ totalMs: endMs - turn.startMs,
287
+ inputTokens,
288
+ outputTokens,
289
+ stallMs: turn.stallMs,
290
+ stallCount: turn.stallCount,
291
+ rateUsdPerMTokens: validCost && validTokens
292
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
293
+ : null,
294
+ generationMs: turn.generationMs,
295
+ totalTokens,
296
+ costUsd: validCost ? costUsd : 0,
297
+ measurementMs,
298
+ };
299
+ }
300
+
301
+ private endAgent(): TurnTelemetry | undefined {
302
+ const startMs = this.agentStartMs;
303
+ const turns = this.agentTurns;
304
+ this.agentStartMs = null;
305
+ this.agentTurns = [];
306
+ if (startMs === null || turns.length === 0) return;
307
+
308
+ const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
309
+ const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
310
+ const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
311
+ const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
312
+ const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
313
+ const stallCount = turns.reduce((sum, turn) => sum + turn.stallCount, 0);
314
+ const measuredTurns = turns.filter((turn) => turn.measurementMs !== null);
315
+ const measuredOutputTokens = measuredTurns.reduce((sum, turn) => sum + turn.outputTokens, 0);
316
+ const measurementMs = measuredTurns.length > 0
317
+ ? measuredTurns.reduce((sum, turn) => sum + turn.measurementMs!, 0)
318
+ : null;
319
+ const tps =
320
+ measurementMs !== null && measurementMs >= MIN_GENERATION_MS
321
+ ? round(measuredOutputTokens / (measurementMs / 1000), 1)
322
+ : null;
323
+ const boundedTps = tps !== null && tps <= MAX_PLAUSIBLE_TPS ? tps : null;
324
+ const validRate = costUsd > 0 && totalTokens > 0;
325
+ return {
326
+ tps: boundedTps,
327
+ ttftMs: turns[0]!.ttftMs,
328
+ totalMs: this.now() - startMs,
329
+ inputTokens,
330
+ outputTokens,
331
+ stallMs,
332
+ stallCount,
333
+ rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
334
+ generationMs: turns.reduce((sum, turn) => sum + turn.generationMs, 0),
335
+ totalTokens,
336
+ costUsd,
337
+ measurementMs,
338
+ };
339
+ }
340
+ }
341
+
342
+ function formatTurnDuration(ms: number): string {
343
+ return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
344
+ }
345
+
346
+ export function formatTurnTelemetry(
347
+ telemetry: TurnTelemetry,
348
+ theme: Theme,
349
+ config: TelemetryConfig,
350
+ iconMode: IconMode,
351
+ ): string {
352
+ const glyphs = resolveGlyphs(iconMode);
353
+ const parts: string[] = [];
354
+ if (config.tps) {
355
+ const value = telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
356
+ parts.push(theme.fg(telemetry.tps === null ? "muted" : "accent", `${glyphs.speed} TPS ${value}`));
357
+ }
358
+ if (config.ttft) {
359
+ parts.push(theme.fg("text", `${glyphs.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`));
360
+ }
361
+ if (config.duration) {
362
+ parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
363
+ }
364
+ if (config.tokens) {
365
+ parts.push(theme.fg("accent", `${glyphs.input} in ${fmtTokens(telemetry.inputTokens)}`));
366
+ parts.push(theme.fg("success", `${glyphs.output} out ${fmtTokens(telemetry.outputTokens)}`));
367
+ }
368
+ if (config.stalls && telemetry.stallMs > 0) {
369
+ parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
370
+ }
371
+ if (config.cost && telemetry.rateUsdPerMTokens !== null) {
372
+ parts.push(theme.fg("warning", `${glyphs.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`));
373
+ }
374
+ return parts.join(` ${theme.fg("dim", "|")} `);
375
+ }
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "A polished TUI for Pi coding agent: animated logo header, Starship-style footer, rounded editor with model metadata, and prompt-box user messages. Combines the best of pi-haiku, pi-claude-code-tui, and pi-zentui.",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/OldSuns/pi-open-tui.git"
9
+ },
6
10
  "keywords": [
7
11
  "pi-package",
8
12
  "pi",
@@ -20,7 +24,7 @@
20
24
  "LICENSE"
21
25
  ],
22
26
  "scripts": {
23
- "test": "node --test --experimental-transform-types tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts",
27
+ "test": "node --test --experimental-transform-types tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts",
24
28
  "typecheck": "tsc --noEmit"
25
29
  },
26
30
  "pi": {