pi-open-tui 0.2.13 → 0.2.15

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.
@@ -1,298 +1,295 @@
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 = 1000;
17
-
18
- type TelemetryEvent =
19
- | AgentStartEvent
20
- | AgentSettledEvent
21
- | TurnStartEvent
22
- | MessageStartEvent
23
- | MessageUpdateEvent
24
- | MessageEndEvent
25
- | ToolExecutionStartEvent
26
- | TurnEndEvent;
27
- type AgentMessage = MessageStartEvent["message"];
28
- type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
29
-
30
- interface MessageTiming {
31
- lastUpdateMs: number;
32
- firstOutputMs: number | null;
33
- inStall: boolean;
34
- }
35
-
36
- interface TurnTiming {
37
- startMs: number;
38
- firstTokenMs: number | null;
39
- currentMessage: MessageTiming | null;
40
- messages: AssistantMessage[];
41
- generationMs: number;
42
- stallMs: number;
43
- stallCount: number;
44
- }
45
-
46
- export interface TurnTelemetry {
47
- tps: number | null;
48
- ttftMs: number;
49
- totalMs: number;
50
- inputTokens: number;
51
- outputTokens: number;
52
- stallMs: number;
53
- stallCount: number;
54
- rateUsdPerMTokens: number | null;
55
- generationMs: number;
56
- totalTokens: number;
57
- costUsd: number;
58
- measurementMs: number | null;
59
- }
60
-
61
- function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
62
- return message.role === "assistant";
63
- }
64
-
65
- function round(value: number, decimals: number): number {
66
- const factor = 10 ** decimals;
67
- return Math.round(value * factor) / factor;
68
- }
69
-
70
- export class TurnTelemetryTracker {
71
- private readonly now: () => number;
72
- private turn: TurnTiming | undefined;
73
- private agentStartMs: number | null = null;
74
- private agentTurns: TurnTelemetry[] = [];
75
-
76
- constructor(now: () => number = () => performance.now()) {
77
- this.now = now;
78
- }
79
-
80
- handle(event: TelemetryEvent): TurnTelemetry | undefined {
81
- switch (event.type) {
82
- case "agent_start":
83
- if (this.agentStartMs === null) {
84
- this.agentStartMs = this.now();
85
- this.agentTurns = [];
86
- }
87
- return;
88
- case "agent_settled":
89
- return this.endAgent();
90
- case "turn_start":
91
- this.startTurn();
92
- return;
93
- case "message_start":
94
- this.startMessage(event.message);
95
- return;
96
- case "message_update":
97
- this.updateMessage(event);
98
- return;
99
- case "message_end":
100
- this.endMessage(event.message);
101
- return;
102
- case "tool_execution_start":
103
- return;
104
- case "turn_end":
105
- return this.endTurnAndCollect();
106
- }
107
- }
108
-
109
- private startTurn(): void {
110
- this.turn = {
111
- startMs: this.now(),
112
- firstTokenMs: null,
113
- currentMessage: null,
114
- messages: [],
115
- generationMs: 0,
116
- stallMs: 0,
117
- stallCount: 0,
118
- };
119
- }
120
-
121
- private startMessage(message: AgentMessage): void {
122
- if (!this.turn || !isAssistantMessage(message)) return;
123
- const now = this.now();
124
- this.turn.currentMessage = {
125
- lastUpdateMs: now,
126
- firstOutputMs: null,
127
- inStall: false,
128
- };
129
- }
130
-
131
- private updateMessage(event: MessageUpdateEvent): void {
132
- const turn = this.turn;
133
- const current = turn?.currentMessage;
134
- const streamEvent = event.assistantMessageEvent;
135
- if (
136
- streamEvent.type !== "text_delta" &&
137
- streamEvent.type !== "thinking_delta" &&
138
- streamEvent.type !== "toolcall_delta"
139
- ) return;
140
- if (streamEvent.delta.length === 0) return;
141
- const message = event.message;
142
- if (!turn || !current || !isAssistantMessage(message)) return;
143
-
144
- const now = this.now();
145
- if (current.firstOutputMs === null) {
146
- current.firstOutputMs = now;
147
- turn.firstTokenMs ??= now;
148
- current.lastUpdateMs = now;
149
- return;
150
- }
151
-
152
- const gap = now - current.lastUpdateMs;
153
- if (gap >= STALL_THRESHOLD_MS) {
154
- if (!current.inStall) turn.stallCount++;
155
- current.inStall = true;
156
- turn.stallMs += gap;
157
- } else {
158
- current.inStall = false;
159
- }
160
- current.lastUpdateMs = now;
161
- }
162
-
163
- private endMessage(message: AgentMessage): void {
164
- const turn = this.turn;
165
- if (!turn || !isAssistantMessage(message)) return;
166
-
167
- const current = turn.currentMessage;
168
- if (current) {
169
- const endMs = this.now();
170
- turn.generationMs = endMs - turn.startMs;
171
- if (current.firstOutputMs === null && message.usage.output > 0) {
172
- turn.firstTokenMs ??= endMs;
173
- }
174
- turn.currentMessage = null;
175
- }
176
- turn.messages.push(message);
177
- }
178
-
179
- private endTurnAndCollect(): TurnTelemetry | undefined {
180
- const telemetry = this.endTurn();
181
- if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
182
- return telemetry;
183
- }
184
-
185
- private endTurn(): TurnTelemetry | undefined {
186
- const turn = this.turn;
187
- this.turn = undefined;
188
- if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
189
-
190
- const endMs = this.now();
191
- let inputTokens = 0;
192
- let outputTokens = 0;
193
- let totalTokens = 0;
194
- let costUsd = 0;
195
- for (const message of turn.messages) {
196
- inputTokens += message.usage.input;
197
- outputTokens += message.usage.output;
198
- totalTokens += message.usage.totalTokens;
199
- costUsd += message.usage.cost.total;
200
- }
201
- if (![inputTokens, outputTokens, totalTokens, costUsd].every(Number.isFinite)) {
202
- throw new Error("Invalid assistant usage in turn telemetry");
203
- }
204
-
205
- const measurementMs = outputTokens > 0 && turn.generationMs > 0 ? turn.generationMs : null;
206
- const tps = measurementMs === null
207
- ? null
208
- : round(outputTokens / (measurementMs / 1000), 1);
209
- const validCost = Number.isFinite(costUsd) && costUsd > 0;
210
- const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
211
- return {
212
- tps,
213
- ttftMs: turn.firstTokenMs - turn.startMs,
214
- totalMs: endMs - turn.startMs,
215
- inputTokens,
216
- outputTokens,
217
- stallMs: turn.stallMs,
218
- stallCount: turn.stallCount,
219
- rateUsdPerMTokens: validCost && validTokens
220
- ? round(costUsd / (totalTokens / 1_000_000), 2)
221
- : null,
222
- generationMs: turn.generationMs,
223
- totalTokens,
224
- costUsd: validCost ? costUsd : 0,
225
- measurementMs,
226
- };
227
- }
228
-
229
- private endAgent(): TurnTelemetry | undefined {
230
- const startMs = this.agentStartMs;
231
- const turns = this.agentTurns;
232
- this.agentStartMs = null;
233
- this.agentTurns = [];
234
- if (startMs === null || turns.length === 0) return;
235
-
236
- const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
237
- const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
238
- const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
239
- const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
240
- const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
241
- const stallCount = turns.reduce((sum, turn) => sum + turn.stallCount, 0);
242
- const generationMs = turns.reduce((sum, turn) => sum + turn.generationMs, 0);
243
- const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
244
- const tps = measurementMs === null
245
- ? null
246
- : round(outputTokens / (measurementMs / 1000), 1);
247
- const validRate = costUsd > 0 && totalTokens > 0;
248
- return {
249
- tps,
250
- ttftMs: turns[0]!.ttftMs,
251
- totalMs: this.now() - startMs,
252
- inputTokens,
253
- outputTokens,
254
- stallMs,
255
- stallCount,
256
- rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
257
- generationMs,
258
- totalTokens,
259
- costUsd,
260
- measurementMs,
261
- };
262
- }
263
- }
264
-
265
- function formatTurnDuration(ms: number): string {
266
- return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
267
- }
268
-
269
- export function formatTurnTelemetry(
270
- telemetry: TurnTelemetry,
271
- theme: Theme,
272
- config: TelemetryConfig,
273
- iconMode: IconMode,
274
- ): string {
275
- const glyphs = resolveGlyphs(iconMode);
276
- const parts: string[] = [];
277
- if (config.tps) {
278
- const value = telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
279
- parts.push(theme.fg(telemetry.tps === null ? "muted" : "accent", `${glyphs.speed} TPS ${value}`));
280
- }
281
- if (config.ttft) {
282
- parts.push(theme.fg("text", `${glyphs.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`));
283
- }
284
- if (config.duration) {
285
- parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
286
- }
287
- if (config.tokens) {
288
- parts.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(telemetry.inputTokens)}`));
289
- parts.push(theme.fg("success", `${glyphs.output} ${fmtTokens(telemetry.outputTokens)}`));
290
- }
291
- if (config.stalls && telemetry.stallMs > 0) {
292
- parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
293
- }
294
- if (config.cost && telemetry.rateUsdPerMTokens !== null) {
295
- parts.push(theme.fg("warning", `${glyphs.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`));
296
- }
297
- return parts.join(` ${theme.fg("dim", "|")} `);
298
- }
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 { finiteOrZero, fmtTokens, formatDuration } from "./utils.ts";
15
+
16
+ const STALL_THRESHOLD_MS = 1000;
17
+
18
+ type TelemetryEvent =
19
+ | AgentStartEvent
20
+ | AgentSettledEvent
21
+ | TurnStartEvent
22
+ | MessageStartEvent
23
+ | MessageUpdateEvent
24
+ | MessageEndEvent
25
+ | ToolExecutionStartEvent
26
+ | TurnEndEvent;
27
+ type AgentMessage = MessageStartEvent["message"];
28
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
29
+
30
+ interface MessageTiming {
31
+ lastUpdateMs: number;
32
+ firstOutputMs: number | null;
33
+ inStall: boolean;
34
+ }
35
+
36
+ interface TurnTiming {
37
+ startMs: number;
38
+ firstTokenMs: number | null;
39
+ currentMessage: MessageTiming | null;
40
+ messages: AssistantMessage[];
41
+ generationMs: number;
42
+ stallMs: number;
43
+ stallCount: number;
44
+ }
45
+
46
+ export interface TurnTelemetry {
47
+ tps: number | null;
48
+ ttftMs: number;
49
+ totalMs: number;
50
+ inputTokens: number;
51
+ outputTokens: number;
52
+ stallMs: number;
53
+ stallCount: number;
54
+ rateUsdPerMTokens: number | null;
55
+ generationMs: number;
56
+ totalTokens: number;
57
+ costUsd: number;
58
+ measurementMs: number | null;
59
+ }
60
+
61
+ function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
62
+ return message.role === "assistant";
63
+ }
64
+
65
+ function round(value: number, decimals: number): number {
66
+ const factor = 10 ** decimals;
67
+ return Math.round(value * factor) / factor;
68
+ }
69
+
70
+ export class TurnTelemetryTracker {
71
+ private readonly now: () => number;
72
+ private turn: TurnTiming | undefined;
73
+ private agentStartMs: number | null = null;
74
+ private agentTurns: TurnTelemetry[] = [];
75
+
76
+ constructor(now: () => number = () => performance.now()) {
77
+ this.now = now;
78
+ }
79
+
80
+ handle(event: TelemetryEvent): TurnTelemetry | undefined {
81
+ switch (event.type) {
82
+ case "agent_start":
83
+ if (this.agentStartMs === null) {
84
+ this.agentStartMs = this.now();
85
+ this.agentTurns = [];
86
+ }
87
+ return;
88
+ case "agent_settled":
89
+ return this.endAgent();
90
+ case "turn_start":
91
+ this.startTurn();
92
+ return;
93
+ case "message_start":
94
+ this.startMessage(event.message);
95
+ return;
96
+ case "message_update":
97
+ this.updateMessage(event);
98
+ return;
99
+ case "message_end":
100
+ this.endMessage(event.message);
101
+ return;
102
+ case "tool_execution_start":
103
+ return;
104
+ case "turn_end":
105
+ return this.endTurnAndCollect();
106
+ }
107
+ }
108
+
109
+ private startTurn(): void {
110
+ this.turn = {
111
+ startMs: this.now(),
112
+ firstTokenMs: null,
113
+ currentMessage: null,
114
+ messages: [],
115
+ generationMs: 0,
116
+ stallMs: 0,
117
+ stallCount: 0,
118
+ };
119
+ }
120
+
121
+ private startMessage(message: AgentMessage): void {
122
+ if (!this.turn || !isAssistantMessage(message)) return;
123
+ const now = this.now();
124
+ this.turn.currentMessage = {
125
+ lastUpdateMs: now,
126
+ firstOutputMs: null,
127
+ inStall: false,
128
+ };
129
+ }
130
+
131
+ private updateMessage(event: MessageUpdateEvent): void {
132
+ const turn = this.turn;
133
+ const current = turn?.currentMessage;
134
+ const streamEvent = event.assistantMessageEvent;
135
+ if (
136
+ streamEvent.type !== "text_delta" &&
137
+ streamEvent.type !== "thinking_delta" &&
138
+ streamEvent.type !== "toolcall_delta"
139
+ ) return;
140
+ if (streamEvent.delta.length === 0) return;
141
+ const message = event.message;
142
+ if (!turn || !current || !isAssistantMessage(message)) return;
143
+
144
+ const now = this.now();
145
+ if (current.firstOutputMs === null) {
146
+ current.firstOutputMs = now;
147
+ turn.firstTokenMs ??= now;
148
+ current.lastUpdateMs = now;
149
+ return;
150
+ }
151
+
152
+ const gap = now - current.lastUpdateMs;
153
+ if (gap >= STALL_THRESHOLD_MS) {
154
+ if (!current.inStall) turn.stallCount++;
155
+ current.inStall = true;
156
+ turn.stallMs += gap;
157
+ } else {
158
+ current.inStall = false;
159
+ }
160
+ current.lastUpdateMs = now;
161
+ }
162
+
163
+ private endMessage(message: AgentMessage): void {
164
+ const turn = this.turn;
165
+ if (!turn || !isAssistantMessage(message)) return;
166
+
167
+ const current = turn.currentMessage;
168
+ if (current) {
169
+ const endMs = this.now();
170
+ turn.generationMs = endMs - turn.startMs;
171
+ if (current.firstOutputMs === null && finiteOrZero(message.usage?.output) > 0) {
172
+ turn.firstTokenMs ??= endMs;
173
+ }
174
+ turn.currentMessage = null;
175
+ }
176
+ turn.messages.push(message);
177
+ }
178
+
179
+ private endTurnAndCollect(): TurnTelemetry | undefined {
180
+ const telemetry = this.endTurn();
181
+ if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
182
+ return telemetry;
183
+ }
184
+
185
+ private endTurn(): TurnTelemetry | undefined {
186
+ const turn = this.turn;
187
+ this.turn = undefined;
188
+ if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
189
+
190
+ const endMs = this.now();
191
+ let inputTokens = 0;
192
+ let outputTokens = 0;
193
+ let totalTokens = 0;
194
+ let costUsd = 0;
195
+ for (const message of turn.messages) {
196
+ inputTokens += finiteOrZero(message.usage?.input);
197
+ outputTokens += finiteOrZero(message.usage?.output);
198
+ totalTokens += finiteOrZero(message.usage?.totalTokens);
199
+ costUsd += finiteOrZero(message.usage?.cost?.total);
200
+ }
201
+
202
+ const measurementMs = outputTokens > 0 && turn.generationMs > 0 ? turn.generationMs : null;
203
+ const tps = measurementMs === null
204
+ ? null
205
+ : round(outputTokens / (measurementMs / 1000), 1);
206
+ const validCost = Number.isFinite(costUsd) && costUsd > 0;
207
+ const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
208
+ return {
209
+ tps,
210
+ ttftMs: turn.firstTokenMs - turn.startMs,
211
+ totalMs: endMs - turn.startMs,
212
+ inputTokens,
213
+ outputTokens,
214
+ stallMs: turn.stallMs,
215
+ stallCount: turn.stallCount,
216
+ rateUsdPerMTokens: validCost && validTokens
217
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
218
+ : null,
219
+ generationMs: turn.generationMs,
220
+ totalTokens,
221
+ costUsd: validCost ? costUsd : 0,
222
+ measurementMs,
223
+ };
224
+ }
225
+
226
+ private endAgent(): TurnTelemetry | undefined {
227
+ const startMs = this.agentStartMs;
228
+ const turns = this.agentTurns;
229
+ this.agentStartMs = null;
230
+ this.agentTurns = [];
231
+ if (startMs === null || turns.length === 0) return;
232
+
233
+ const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
234
+ const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
235
+ const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
236
+ const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
237
+ const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
238
+ const stallCount = turns.reduce((sum, turn) => sum + turn.stallCount, 0);
239
+ const generationMs = turns.reduce((sum, turn) => sum + turn.generationMs, 0);
240
+ const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
241
+ const tps = measurementMs === null
242
+ ? null
243
+ : round(outputTokens / (measurementMs / 1000), 1);
244
+ const validRate = costUsd > 0 && totalTokens > 0;
245
+ return {
246
+ tps,
247
+ ttftMs: turns[0]!.ttftMs,
248
+ totalMs: this.now() - startMs,
249
+ inputTokens,
250
+ outputTokens,
251
+ stallMs,
252
+ stallCount,
253
+ rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
254
+ generationMs,
255
+ totalTokens,
256
+ costUsd,
257
+ measurementMs,
258
+ };
259
+ }
260
+ }
261
+
262
+ function formatTurnDuration(ms: number): string {
263
+ return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
264
+ }
265
+
266
+ export function formatTurnTelemetry(
267
+ telemetry: TurnTelemetry,
268
+ theme: Theme,
269
+ config: TelemetryConfig,
270
+ iconMode: IconMode,
271
+ ): string {
272
+ const glyphs = resolveGlyphs(iconMode);
273
+ const parts: string[] = [];
274
+ if (config.tps) {
275
+ const value = telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
276
+ parts.push(theme.fg(telemetry.tps === null ? "muted" : "accent", `${glyphs.speed} TPS ${value}`));
277
+ }
278
+ if (config.ttft) {
279
+ parts.push(theme.fg("text", `${glyphs.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`));
280
+ }
281
+ if (config.duration) {
282
+ parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
283
+ }
284
+ if (config.tokens) {
285
+ parts.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(telemetry.inputTokens)}`));
286
+ parts.push(theme.fg("success", `${glyphs.output} ${fmtTokens(telemetry.outputTokens)}`));
287
+ }
288
+ if (config.stalls && telemetry.stallMs > 0) {
289
+ parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
290
+ }
291
+ if (config.cost && telemetry.rateUsdPerMTokens !== null) {
292
+ parts.push(theme.fg("warning", `${glyphs.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`));
293
+ }
294
+ return parts.join(` ${theme.fg("dim", "|")} `);
295
+ }