pi-open-tui 0.2.6 → 0.2.8

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
@@ -68,12 +68,12 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
68
68
  After each complete agent run, open-tui shows one transient notification. Tool-call turns are aggregated into that single result:
69
69
 
70
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
71
+ > TPS 42.5 tok/s | ~ TTFT 1.2s | + 29.7s | 567 | 1.2k | ! stall 1x / 4.3s | $ $3.60/M
72
72
  ```
73
73
 
74
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
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.
76
+ TPS is the actual output delivery rate: provider-reported output tokens divided by the time from the first meaningful output delta through `message_end`. It excludes time-to-first-token and tool execution between assistant messages, but includes stalls during streaming. Multi-message and multi-turn runs sum only measurable token-duration pairs before calculating the rate, so an unmeasurable short tool response does not pollute or hide a measurable final response. Messages with fewer than two meaningful deltas or less than 200 ms of observed delivery time are shown as `TPS —` unless another measurable message exists in the run. 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
77
 
78
78
  ## Local development
79
79
 
@@ -95,3 +95,5 @@ This project builds on the work of several Pi community packages:
95
95
  - **[pi-tps](https://github.com/monotykamary/pi-tps)** — the turn timing, stall detection, and conservative TPS measurement approach
96
96
 
97
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`.
98
+
99
+ Special thanks to the **[LINUX DO](https://linux.do)** community for their support.
Binary file
@@ -36,8 +36,9 @@ const NERD_GLYPHS: IconGlyphs = {
36
36
  context: "",
37
37
  model: "",
38
38
  thinking: "",
39
- input: "",
40
- output: "",
39
+ // client network view: input = upload to API, output = download from API
40
+ input: "",
41
+ output: "",
41
42
  cacheHit: "",
42
43
  cost: "",
43
44
  speed: "󰓅",
@@ -67,8 +68,8 @@ const ASCII_GLYPHS: IconGlyphs = {
67
68
  context: "%",
68
69
  model: "M",
69
70
  thinking: "~",
70
- input: "",
71
- output: "",
71
+ input: "",
72
+ output: "",
72
73
  cacheHit: "c",
73
74
  cost: "$",
74
75
  speed: ">",
@@ -144,16 +144,22 @@ class SettingsUi implements SettingsUiHandle {
144
144
  private selectList: SelectList;
145
145
  private readonly selectedItemByTab: Partial<Record<Tab, string>> = {};
146
146
  private readonly container: Box;
147
+ private readonly theme: Theme;
148
+ private readonly onChange: (config: OpenTuiConfig) => void;
149
+ private readonly onClose: () => void;
147
150
  private cachedWidth: number | undefined;
148
151
  private cachedLines: string[] | undefined;
149
152
 
150
153
  constructor(
151
- private readonly theme: Theme,
154
+ theme: Theme,
152
155
  config: OpenTuiConfig,
153
- private readonly onChange: (config: OpenTuiConfig) => void,
154
- private readonly onClose: () => void,
156
+ onChange: (config: OpenTuiConfig) => void,
157
+ onClose: () => void,
155
158
  ) {
159
+ this.theme = theme;
156
160
  this.config = config;
161
+ this.onChange = onChange;
162
+ this.onClose = onClose;
157
163
  this.container = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
158
164
  this.selectList = new SelectList([], 12, {
159
165
  selectedPrefix: (t) => theme.fg("accent", t),
@@ -13,13 +13,9 @@ import type { IconMode, TelemetryConfig } from "./config.ts";
13
13
  import { resolveGlyphs } from "./icons.ts";
14
14
  import { fmtTokens, formatDuration } from "./utils.ts";
15
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;
16
+ const STALL_THRESHOLD_MS = 1000;
17
+ const MIN_STREAM_UPDATES = 2;
18
+ const MIN_MEASUREMENT_MS = 200;
23
19
 
24
20
  type TelemetryEvent =
25
21
  | AgentStartEvent
@@ -36,26 +32,25 @@ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
36
32
  interface MessageTiming {
37
33
  startMs: number;
38
34
  lastUpdateMs: number;
39
- firstUpdateSeen: boolean;
35
+ firstOutputMs: number | null;
40
36
  streamUpdateCount: number;
41
- firstStreamUpdateMs: number | null;
42
- lastStreamUpdateMs: number | null;
43
37
  inStall: boolean;
44
38
  }
45
39
 
40
+ interface MessageMeasurement {
41
+ outputTokens: number;
42
+ durationMs: number;
43
+ }
44
+
46
45
  interface TurnTiming {
47
46
  startMs: number;
48
47
  firstTokenMs: number | null;
49
48
  currentMessage: MessageTiming | null;
50
49
  messages: AssistantMessage[];
50
+ measurements: MessageMeasurement[];
51
51
  generationMs: number;
52
- streamMs: number;
53
- streamIntervals: number;
54
- streamUpdateCount: number;
55
52
  stallMs: number;
56
53
  stallCount: number;
57
- isToolCall: boolean;
58
- modelKey: string | null;
59
54
  }
60
55
 
61
56
  export interface TurnTelemetry {
@@ -73,6 +68,11 @@ export interface TurnTelemetry {
73
68
  measurementMs: number | null;
74
69
  }
75
70
 
71
+ interface MeasuredTurn {
72
+ telemetry: TurnTelemetry;
73
+ measuredOutputTokens: number;
74
+ }
75
+
76
76
  function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
77
77
  return message.role === "assistant";
78
78
  }
@@ -84,10 +84,9 @@ function round(value: number, decimals: number): number {
84
84
 
85
85
  export class TurnTelemetryTracker {
86
86
  private readonly now: () => number;
87
- private readonly tpsCaps = new Map<string, number>();
88
87
  private turn: TurnTiming | undefined;
89
88
  private agentStartMs: number | null = null;
90
- private agentTurns: TurnTelemetry[] = [];
89
+ private agentTurns: MeasuredTurn[] = [];
91
90
 
92
91
  constructor(now: () => number = () => performance.now()) {
93
92
  this.now = now;
@@ -116,7 +115,6 @@ export class TurnTelemetryTracker {
116
115
  this.endMessage(event.message);
117
116
  return;
118
117
  case "tool_execution_start":
119
- if (this.turn) this.turn.isToolCall = true;
120
118
  return;
121
119
  case "turn_end":
122
120
  return this.endTurnAndCollect();
@@ -129,14 +127,10 @@ export class TurnTelemetryTracker {
129
127
  firstTokenMs: null,
130
128
  currentMessage: null,
131
129
  messages: [],
130
+ measurements: [],
132
131
  generationMs: 0,
133
- streamMs: 0,
134
- streamIntervals: 0,
135
- streamUpdateCount: 0,
136
132
  stallMs: 0,
137
133
  stallCount: 0,
138
- isToolCall: false,
139
- modelKey: null,
140
134
  };
141
135
  }
142
136
 
@@ -146,13 +140,10 @@ export class TurnTelemetryTracker {
146
140
  this.turn.currentMessage = {
147
141
  startMs: now,
148
142
  lastUpdateMs: now,
149
- firstUpdateSeen: false,
143
+ firstOutputMs: null,
150
144
  streamUpdateCount: 0,
151
- firstStreamUpdateMs: null,
152
- lastStreamUpdateMs: null,
153
145
  inStall: false,
154
146
  };
155
- this.turn.modelKey ??= `${message.provider}:${message.model}`;
156
147
  }
157
148
 
158
149
  private updateMessage(event: MessageUpdateEvent): void {
@@ -169,16 +160,15 @@ export class TurnTelemetryTracker {
169
160
  if (!turn || !current || !isAssistantMessage(message)) return;
170
161
 
171
162
  const now = this.now();
172
- if (!current.firstUpdateSeen) {
173
- current.firstUpdateSeen = true;
163
+ if (current.firstOutputMs === null) {
164
+ current.firstOutputMs = now;
174
165
  turn.firstTokenMs ??= now;
166
+ current.streamUpdateCount = 1;
175
167
  current.lastUpdateMs = now;
176
168
  return;
177
169
  }
178
170
 
179
171
  current.streamUpdateCount++;
180
- current.firstStreamUpdateMs ??= now;
181
- current.lastStreamUpdateMs = now;
182
172
 
183
173
  const gap = now - current.lastUpdateMs;
184
174
  if (gap >= STALL_THRESHOLD_MS) {
@@ -197,26 +187,31 @@ export class TurnTelemetryTracker {
197
187
 
198
188
  const current = turn.currentMessage;
199
189
  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;
190
+ const endMs = this.now();
191
+ turn.generationMs += endMs - current.startMs;
192
+ if (current.firstOutputMs === null && message.usage.output > 0) {
193
+ turn.firstTokenMs ??= endMs;
194
+ }
195
+ const measurementMs = current.firstOutputMs === null ? 0 : endMs - current.firstOutputMs;
196
+ if (
197
+ message.usage.output > 0 &&
198
+ current.streamUpdateCount >= MIN_STREAM_UPDATES &&
199
+ measurementMs >= MIN_MEASUREMENT_MS
200
+ ) {
201
+ turn.measurements.push({ outputTokens: message.usage.output, durationMs: measurementMs });
206
202
  }
207
203
  turn.currentMessage = null;
208
204
  }
209
205
  turn.messages.push(message);
210
- turn.modelKey ??= `${message.provider}:${message.model}`;
211
206
  }
212
207
 
213
208
  private endTurnAndCollect(): TurnTelemetry | undefined {
214
- const telemetry = this.endTurn();
215
- if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
216
- return telemetry;
209
+ const result = this.endTurn();
210
+ if (result && this.agentStartMs !== null) this.agentTurns.push(result);
211
+ return result?.telemetry;
217
212
  }
218
213
 
219
- private endTurn(): TurnTelemetry | undefined {
214
+ private endTurn(): MeasuredTurn | undefined {
220
215
  const turn = this.turn;
221
216
  this.turn = undefined;
222
217
  if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
@@ -235,66 +230,33 @@ export class TurnTelemetryTracker {
235
230
  if (![inputTokens, outputTokens, totalTokens, costUsd].every(Number.isFinite)) {
236
231
  throw new Error("Invalid assistant usage in turn telemetry");
237
232
  }
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
233
 
234
+ const measuredOutputTokens = turn.measurements.reduce((sum, measurement) => sum + measurement.outputTokens, 0);
235
+ const measuredMs = turn.measurements.reduce((sum, measurement) => sum + measurement.durationMs, 0);
236
+ const measurementMs = measuredMs > 0 ? measuredMs : null;
237
+ const tps = measurementMs === null
238
+ ? null
239
+ : round(measuredOutputTokens / (measurementMs / 1000), 1);
281
240
  const validCost = Number.isFinite(costUsd) && costUsd > 0;
282
241
  const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
283
242
  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,
243
+ telemetry: {
244
+ tps,
245
+ ttftMs: turn.firstTokenMs - turn.startMs,
246
+ totalMs: endMs - turn.startMs,
247
+ inputTokens,
248
+ outputTokens,
249
+ stallMs: turn.stallMs,
250
+ stallCount: turn.stallCount,
251
+ rateUsdPerMTokens: validCost && validTokens
252
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
253
+ : null,
254
+ generationMs: turn.generationMs,
255
+ totalTokens,
256
+ costUsd: validCost ? costUsd : 0,
257
+ measurementMs,
258
+ },
259
+ measuredOutputTokens,
298
260
  };
299
261
  }
300
262
 
@@ -305,33 +267,29 @@ export class TurnTelemetryTracker {
305
267
  this.agentTurns = [];
306
268
  if (startMs === null || turns.length === 0) return;
307
269
 
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;
270
+ const outputTokens = turns.reduce((sum, turn) => sum + turn.telemetry.outputTokens, 0);
271
+ const inputTokens = turns.reduce((sum, turn) => sum + turn.telemetry.inputTokens, 0);
272
+ const totalTokens = turns.reduce((sum, turn) => sum + turn.telemetry.totalTokens, 0);
273
+ const costUsd = turns.reduce((sum, turn) => sum + turn.telemetry.costUsd, 0);
274
+ const stallMs = turns.reduce((sum, turn) => sum + turn.telemetry.stallMs, 0);
275
+ const stallCount = turns.reduce((sum, turn) => sum + turn.telemetry.stallCount, 0);
276
+ const measuredOutputTokens = turns.reduce((sum, turn) => sum + turn.measuredOutputTokens, 0);
277
+ const measuredMs = turns.reduce((sum, turn) => sum + (turn.telemetry.measurementMs ?? 0), 0);
278
+ const measurementMs = measuredMs > 0 ? measuredMs : null;
279
+ const tps = measurementMs === null
280
+ ? null
281
+ : round(measuredOutputTokens / (measurementMs / 1000), 1);
324
282
  const validRate = costUsd > 0 && totalTokens > 0;
325
283
  return {
326
- tps: boundedTps,
327
- ttftMs: turns[0]!.ttftMs,
284
+ tps,
285
+ ttftMs: turns[0]!.telemetry.ttftMs,
328
286
  totalMs: this.now() - startMs,
329
287
  inputTokens,
330
288
  outputTokens,
331
289
  stallMs,
332
290
  stallCount,
333
291
  rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
334
- generationMs: turns.reduce((sum, turn) => sum + turn.generationMs, 0),
292
+ generationMs: turns.reduce((sum, turn) => sum + turn.telemetry.generationMs, 0),
335
293
  totalTokens,
336
294
  costUsd,
337
295
  measurementMs,
@@ -362,8 +320,8 @@ export function formatTurnTelemetry(
362
320
  parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
363
321
  }
364
322
  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)}`));
323
+ parts.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(telemetry.inputTokens)}`));
324
+ parts.push(theme.fg("success", `${glyphs.output} ${fmtTokens(telemetry.outputTokens)}`));
367
325
  }
368
326
  if (config.stalls && telemetry.stallMs > 0) {
369
327
  parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "LICENSE"
25
25
  ],
26
26
  "scripts": {
27
- "test": "node --test --experimental-transform-types tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts",
27
+ "test": "node --test tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts",
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "pi": {