pi-open-tui 0.2.8 → 0.2.10

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
@@ -14,7 +14,7 @@ A polished TUI for [Pi](https://pi.dev) coding agent. Combines the best of pi-ha
14
14
  - **Working timer** — live elapsed time while the agent is working, done duration when finished
15
15
  - **Turn telemetry** — generation speed, TTFT, stalls, tokens, and list-price rate after each complete agent run
16
16
  - **Zero prototype patches** — uses public Pi APIs (setHeader/setFooter/setEditorComponent), safe across Pi updates
17
- - **Interactive settings UI** — `/open-tui` opens a tabbed settings dialog (Features / Icons / Segments / Telemetry)
17
+ - **Interactive settings UI** — `/open-tui` opens a tabbed settings dialog (General / Icons / Footer / Telemetry)
18
18
 
19
19
  ## Install
20
20
 
@@ -35,6 +35,7 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
35
35
  ```json
36
36
  {
37
37
  "enabled": true,
38
+ "settingsLanguage": "en",
38
39
  "icons": {
39
40
  "mode": "auto"
40
41
  },
@@ -46,7 +47,8 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
46
47
  "runtime": true,
47
48
  "context": true,
48
49
  "tokens": true,
49
- "cost": true
50
+ "cost": true,
51
+ "extensionStatuses": true
50
52
  },
51
53
  "telemetry": {
52
54
  "enabled": true,
@@ -60,8 +62,10 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
60
62
  }
61
63
  ```
62
64
 
65
+ - `settingsLanguage`: language for the `/open-tui` settings UI only; `en` or `zh`
63
66
  - `icons.mode`: `auto` (detect Nerd Font), `nerd` (force Nerd Font glyphs), or `ascii` (plain fallbacks)
64
67
  - `footerSegments.gitCommit`: shows short hash + tag on detached HEAD (off by default)
68
+ - `footerSegments.extensionStatuses`: shows statuses published by extensions through Pi's `setStatus()` API (on by default); turn it off to hide the whole status line, including MCP
65
69
 
66
70
  ## Turn telemetry
67
71
 
@@ -73,7 +77,7 @@ After each complete agent run, open-tui shows one transient notification. Tool-c
73
77
 
74
78
  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
79
 
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.
80
+ TPS is the complete generation throughput for the agent run: all provider-reported assistant output tokens divided by the summed generation time of every LLM turn, measured from `turn_start` through the assistant `message_end`. This includes time-to-first-token, hidden reasoning, buffering, and stalls so the token count and timing cover the same interval. Tool execution between turns is excluded. A run with no output tokens or no measurable generation time 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
81
 
78
82
  ## Local development
79
83
 
@@ -3,6 +3,8 @@ import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import type { IconMode } from "./icons.ts";
5
5
 
6
+ export type SettingsLanguage = "en" | "zh";
7
+
6
8
  export type { IconMode } from "./icons.ts";
7
9
 
8
10
  export interface FooterSegments {
@@ -14,6 +16,7 @@ export interface FooterSegments {
14
16
  context: boolean;
15
17
  tokens: boolean;
16
18
  cost: boolean;
19
+ extensionStatuses: boolean;
17
20
  }
18
21
 
19
22
  export interface TelemetryConfig {
@@ -28,6 +31,7 @@ export interface TelemetryConfig {
28
31
 
29
32
  export interface OpenTuiConfig {
30
33
  enabled: boolean;
34
+ settingsLanguage: SettingsLanguage;
31
35
  icons: {
32
36
  mode: IconMode;
33
37
  };
@@ -37,6 +41,7 @@ export interface OpenTuiConfig {
37
41
 
38
42
  export const DEFAULT_CONFIG: OpenTuiConfig = {
39
43
  enabled: true,
44
+ settingsLanguage: "en",
40
45
  icons: {
41
46
  mode: "auto",
42
47
  },
@@ -49,6 +54,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
49
54
  context: true,
50
55
  tokens: true,
51
56
  cost: true,
57
+ extensionStatuses: true,
52
58
  },
53
59
  telemetry: {
54
60
  enabled: true,
@@ -110,7 +116,11 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
110
116
  try {
111
117
  const raw = readFileSync(path, "utf8");
112
118
  const parsed: unknown = JSON.parse(raw);
113
- return deepMerge(DEFAULT_CONFIG, parsed);
119
+ const config = deepMerge(DEFAULT_CONFIG, parsed);
120
+ if (config.settingsLanguage !== "en" && config.settingsLanguage !== "zh") {
121
+ config.settingsLanguage = DEFAULT_CONFIG.settingsLanguage;
122
+ }
123
+ return config;
114
124
  } catch (err) {
115
125
  notify?.(`open-tui config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
116
126
  return structuredClone(DEFAULT_CONFIG);
@@ -262,15 +262,17 @@ export function installFooter(
262
262
 
263
263
  const mainLines = [line1, line2]
264
264
  .map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
265
- return [
266
- ...mainLines,
267
- ...renderExtensionStatusLines(
268
- theme,
269
- footerData.getExtensionStatuses(),
270
- glyphs,
271
- width,
272
- ),
273
- ];
265
+ return segments.extensionStatuses
266
+ ? [
267
+ ...mainLines,
268
+ ...renderExtensionStatusLines(
269
+ theme,
270
+ footerData.getExtensionStatuses(),
271
+ glyphs,
272
+ width,
273
+ ),
274
+ ]
275
+ : mainLines;
274
276
  },
275
277
  };
276
278
  });
@@ -8,23 +8,80 @@ import {
8
8
  type TUI,
9
9
  Text,
10
10
  } from "@earendil-works/pi-tui";
11
- import type { IconMode, OpenTuiConfig } from "./config.ts";
11
+ import type { IconMode, OpenTuiConfig, SettingsLanguage } from "./config.ts";
12
12
 
13
13
  interface SettingItem {
14
14
  id: string;
15
15
  label: string;
16
16
  currentValue: string;
17
- values: string[];
18
17
  }
19
18
 
20
19
  type Tab = "features" | "icons" | "segments" | "telemetry";
21
20
 
22
- const TABS: { id: Tab; label: string }[] = [
23
- { id: "features", label: "Features" },
24
- { id: "icons", label: "Icons" },
25
- { id: "segments", label: "Segments" },
26
- { id: "telemetry", label: "Telemetry" },
27
- ];
21
+ const TABS: Tab[] = ["features", "icons", "segments", "telemetry"];
22
+
23
+ const COPY = {
24
+ en: {
25
+ title: "Open TUI Settings",
26
+ tabs: { features: "General", icons: "Icons", segments: "Footer", telemetry: "Telemetry" },
27
+ hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Esc/q: close",
28
+ labels: {
29
+ enabled: "Enabled",
30
+ language: "Language",
31
+ iconMode: "Icon mode",
32
+ cwd: "CWD",
33
+ gitBranch: "Git branch",
34
+ gitStatus: "Git status",
35
+ gitCommit: "Git commit (detached)",
36
+ runtime: "Runtime",
37
+ context: "Context bar",
38
+ tokens: "Tokens",
39
+ cost: "Cost",
40
+ extensionStatuses: "Extension status line",
41
+ totalDuration: "Total duration",
42
+ tokenCounts: "Token counts",
43
+ stallDetails: "Stall details",
44
+ costRate: "Cost rate",
45
+ },
46
+ values: {
47
+ on: "On",
48
+ off: "Off",
49
+ languages: { en: "English", zh: "简体中文" },
50
+ icons: { auto: "Auto", nerd: "Nerd", ascii: "ASCII" },
51
+ },
52
+ },
53
+ zh: {
54
+ title: "Open TUI 设置",
55
+ tabs: { features: "常规", icons: "图标", segments: "Footer", telemetry: "遥测" },
56
+ hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · Esc/q:关闭",
57
+ labels: {
58
+ enabled: "启用",
59
+ language: "语言",
60
+ iconMode: "图标模式",
61
+ cwd: "当前目录",
62
+ gitBranch: "Git 分支",
63
+ gitStatus: "Git 状态",
64
+ gitCommit: "Git 提交(分离 HEAD)",
65
+ runtime: "运行环境",
66
+ context: "上下文栏",
67
+ tokens: "Token",
68
+ cost: "费用",
69
+ extensionStatuses: "扩展状态行",
70
+ totalDuration: "总耗时",
71
+ tokenCounts: "Token 数量",
72
+ stallDetails: "停顿详情",
73
+ costRate: "费用速率",
74
+ },
75
+ values: {
76
+ on: "开启",
77
+ off: "关闭",
78
+ languages: { en: "English", zh: "简体中文" },
79
+ icons: { auto: "自动", nerd: "Nerd", ascii: "ASCII" },
80
+ },
81
+ },
82
+ } as const;
83
+
84
+ type SettingsCopy = (typeof COPY)[SettingsLanguage];
28
85
 
29
86
  function toggleSetting(config: OpenTuiConfig, key: keyof OpenTuiConfig["footerSegments"]): OpenTuiConfig {
30
87
  return {
@@ -47,6 +104,10 @@ function toggleEnabled(config: OpenTuiConfig): OpenTuiConfig {
47
104
  return { ...config, enabled: !config.enabled };
48
105
  }
49
106
 
107
+ function toggleLanguage(config: OpenTuiConfig): OpenTuiConfig {
108
+ return { ...config, settingsLanguage: config.settingsLanguage === "en" ? "zh" : "en" };
109
+ }
110
+
50
111
  function toggleTelemetry(config: OpenTuiConfig, key: keyof OpenTuiConfig["telemetry"]): OpenTuiConfig {
51
112
  return {
52
113
  ...config,
@@ -54,61 +115,54 @@ function toggleTelemetry(config: OpenTuiConfig, key: keyof OpenTuiConfig["teleme
54
115
  };
55
116
  }
56
117
 
57
- function buildFeaturesItems(config: OpenTuiConfig): SettingItem[] {
118
+ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
58
119
  return [
59
- {
60
- id: "enabled",
61
- label: "Enabled",
62
- currentValue: config.enabled ? "on" : "off",
63
- values: ["on", "off"],
64
- },
120
+ { id: "enabled", label: copy.labels.enabled, currentValue: config.enabled ? copy.values.on : copy.values.off },
121
+ { id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
65
122
  ];
66
123
  }
67
124
 
68
- function buildIconsItems(config: OpenTuiConfig): SettingItem[] {
69
- return [
70
- {
71
- id: "mode",
72
- label: "Icon mode",
73
- currentValue: config.icons.mode,
74
- values: ["auto", "nerd", "ascii"],
75
- },
76
- ];
125
+ function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
126
+ return [{ id: "mode", label: copy.labels.iconMode, currentValue: copy.values.icons[config.icons.mode] }];
77
127
  }
78
128
 
79
- function buildSegmentsItems(config: OpenTuiConfig): SettingItem[] {
129
+ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
80
130
  const segs = config.footerSegments;
131
+ const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
81
132
  return [
82
- { id: "cwd", label: "cwd", currentValue: segs.cwd ? "on" : "off", values: ["on", "off"] },
83
- { id: "gitBranch", label: "git branch", currentValue: segs.gitBranch ? "on" : "off", values: ["on", "off"] },
84
- { id: "gitStatus", label: "git status", currentValue: segs.gitStatus ? "on" : "off", values: ["on", "off"] },
85
- { id: "gitCommit", label: "git commit (detached)", currentValue: segs.gitCommit ? "on" : "off", values: ["on", "off"] },
86
- { id: "runtime", label: "runtime", currentValue: segs.runtime ? "on" : "off", values: ["on", "off"] },
87
- { id: "context", label: "context bar", currentValue: segs.context ? "on" : "off", values: ["on", "off"] },
88
- { id: "tokens", label: "tokens", currentValue: segs.tokens ? "on" : "off", values: ["on", "off"] },
89
- { id: "cost", label: "cost", currentValue: segs.cost ? "on" : "off", values: ["on", "off"] },
133
+ { id: "cwd", label: copy.labels.cwd, currentValue: flag(segs.cwd) },
134
+ { id: "gitBranch", label: copy.labels.gitBranch, currentValue: flag(segs.gitBranch) },
135
+ { id: "gitStatus", label: copy.labels.gitStatus, currentValue: flag(segs.gitStatus) },
136
+ { id: "gitCommit", label: copy.labels.gitCommit, currentValue: flag(segs.gitCommit) },
137
+ { id: "runtime", label: copy.labels.runtime, currentValue: flag(segs.runtime) },
138
+ { id: "context", label: copy.labels.context, currentValue: flag(segs.context) },
139
+ { id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
140
+ { id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
141
+ { id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
90
142
  ];
91
143
  }
92
144
 
93
- function buildTelemetryItems(config: OpenTuiConfig): SettingItem[] {
145
+ function buildTelemetryItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
94
146
  const telemetry = config.telemetry;
147
+ const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
95
148
  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"] },
149
+ { id: "enabled", label: copy.labels.enabled, currentValue: flag(telemetry.enabled) },
150
+ { id: "tps", label: "TPS", currentValue: flag(telemetry.tps) },
151
+ { id: "ttft", label: "TTFT", currentValue: flag(telemetry.ttft) },
152
+ { id: "duration", label: copy.labels.totalDuration, currentValue: flag(telemetry.duration) },
153
+ { id: "tokens", label: copy.labels.tokenCounts, currentValue: flag(telemetry.tokens) },
154
+ { id: "stalls", label: copy.labels.stallDetails, currentValue: flag(telemetry.stalls) },
155
+ { id: "cost", label: copy.labels.costRate, currentValue: flag(telemetry.cost) },
103
156
  ];
104
157
  }
105
158
 
106
159
  function buildItems(tab: Tab, config: OpenTuiConfig): SettingItem[] {
160
+ const copy = COPY[config.settingsLanguage];
107
161
  switch (tab) {
108
- case "features": return buildFeaturesItems(config);
109
- case "icons": return buildIconsItems(config);
110
- case "segments": return buildSegmentsItems(config);
111
- case "telemetry": return buildTelemetryItems(config);
162
+ case "features": return buildFeaturesItems(config, copy);
163
+ case "icons": return buildIconsItems(config, copy);
164
+ case "segments": return buildSegmentsItems(config, copy);
165
+ case "telemetry": return buildTelemetryItems(config, copy);
112
166
  }
113
167
  }
114
168
 
@@ -117,12 +171,11 @@ function handleSettingChange(
117
171
  itemId: string,
118
172
  config: OpenTuiConfig,
119
173
  ): OpenTuiConfig {
120
- if (tab === "features" && itemId === "enabled") {
121
- return toggleEnabled(config);
122
- }
123
- if (tab === "icons" && itemId === "mode") {
124
- return cycleIconMode(config);
174
+ if (tab === "features") {
175
+ if (itemId === "enabled") return toggleEnabled(config);
176
+ if (itemId === "settingsLanguage") return toggleLanguage(config);
125
177
  }
178
+ if (tab === "icons" && itemId === "mode") return cycleIconMode(config);
126
179
  if (tab === "segments") {
127
180
  return toggleSetting(config, itemId as keyof OpenTuiConfig["footerSegments"]);
128
181
  }
@@ -149,6 +202,7 @@ class SettingsUi implements SettingsUiHandle {
149
202
  private readonly onClose: () => void;
150
203
  private cachedWidth: number | undefined;
151
204
  private cachedLines: string[] | undefined;
205
+ private compact = false;
152
206
 
153
207
  constructor(
154
208
  theme: Theme,
@@ -171,22 +225,36 @@ class SettingsUi implements SettingsUiHandle {
171
225
  this.rebuild();
172
226
  }
173
227
 
228
+ private applySetting(itemId: string): void {
229
+ this.selectedItemByTab[this.tab] = itemId;
230
+ this.config = handleSettingChange(this.tab, itemId, this.config);
231
+ this.onChange(this.config);
232
+ this.rebuild(itemId);
233
+ }
234
+
235
+ private switchTab(offset: number): void {
236
+ const idx = TABS.indexOf(this.tab);
237
+ this.tab = TABS[(idx + offset + TABS.length) % TABS.length]!;
238
+ this.rebuild();
239
+ }
240
+
174
241
  private rebuild(preferredItemId = this.selectedItemByTab[this.tab]): void {
242
+ const copy = COPY[this.config.settingsLanguage];
175
243
  this.container.clear();
176
- this.container.addChild(new Text(this.theme.bold(this.theme.fg("accent", "Open TUI Settings")), 1, 0));
244
+ this.container.addChild(new Text(this.theme.bold(this.theme.fg("accent", copy.title)), 1, 0));
177
245
 
178
- const tabBar = TABS.map((t) => {
179
- const active = t.id === this.tab;
180
- const label = active ? `[${t.label}]` : ` ${t.label} `;
246
+ const tabBar = TABS.map((tab) => {
247
+ const active = tab === this.tab;
248
+ const label = active ? `[${copy.tabs[tab]}]` : ` ${copy.tabs[tab]} `;
181
249
  return active ? this.theme.fg("accent", label) : this.theme.fg("dim", label);
182
250
  }).join(" ");
183
251
  this.container.addChild(new Text(tabBar, 1, 0));
184
- this.container.addChild(new Text(this.theme.fg("dim", "Tab/Shift+Tab to switch · Enter to toggle · Esc to close"), 1, 0));
252
+ this.container.addChild(new Text(this.theme.fg("dim", copy.hint), 1, 0));
185
253
 
186
254
  const items = buildItems(this.tab, this.config).map((item) => ({
187
255
  value: item.id,
188
- label: item.label,
189
- description: item.currentValue,
256
+ label: this.compact ? `${item.label}: ${item.currentValue}` : item.label,
257
+ description: this.compact ? undefined : item.currentValue,
190
258
  } as SelectItem));
191
259
  this.selectList = new SelectList(items, Math.min(items.length, 10), {
192
260
  selectedPrefix: (t) => this.theme.fg("accent", t),
@@ -204,11 +272,7 @@ class SettingsUi implements SettingsUiHandle {
204
272
  this.selectedItemByTab[this.tab] = item.value;
205
273
  };
206
274
  this.selectList.onSelect = (item) => {
207
- this.selectedItemByTab[this.tab] = item.value;
208
- this.config = handleSettingChange(this.tab, item.value, this.config);
209
- this.onChange(this.config);
210
- this.rebuild(item.value);
211
- this.invalidate();
275
+ this.applySetting(item.value);
212
276
  };
213
277
  this.selectList.onCancel = () => {
214
278
  this.onClose();
@@ -220,30 +284,35 @@ class SettingsUi implements SettingsUiHandle {
220
284
  }
221
285
 
222
286
  handleInput(data: string): void {
223
- if (matchesKey(data, Key.tab)) {
224
- const idx = TABS.findIndex((t) => t.id === this.tab);
225
- this.tab = TABS[(idx + 1) % TABS.length]!.id;
226
- this.rebuild();
287
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
288
+ this.switchTab(1);
227
289
  this.invalidate();
228
290
  return;
229
291
  }
230
- if (matchesKey(data, Key.shift("tab"))) {
231
- const idx = TABS.findIndex((t) => t.id === this.tab);
232
- this.tab = TABS[(idx - 1 + TABS.length) % TABS.length]!.id;
233
- this.rebuild();
292
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
293
+ this.switchTab(-1);
234
294
  this.invalidate();
235
295
  return;
236
296
  }
237
- if (matchesKey(data, Key.escape)) {
297
+ if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
238
298
  this.onClose();
239
299
  return;
240
300
  }
241
- this.selectList.handleInput?.(data);
242
- this.invalidate();
301
+ if (matchesKey(data, Key.space) || data === " ") {
302
+ const selected = this.selectList.getSelectedItem();
303
+ if (selected) this.applySetting(selected.value);
304
+ } else {
305
+ this.selectList.handleInput?.(data);
306
+ }
243
307
  this.invalidate();
244
308
  }
245
309
 
246
310
  render(width: number): string[] {
311
+ const compact = width <= 60;
312
+ if (compact !== this.compact) {
313
+ this.compact = compact;
314
+ this.rebuild();
315
+ }
247
316
  if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
248
317
  this.cachedWidth = width;
249
318
  this.cachedLines = this.container.render(width);
@@ -14,8 +14,6 @@ import { resolveGlyphs } from "./icons.ts";
14
14
  import { fmtTokens, formatDuration } from "./utils.ts";
15
15
 
16
16
  const STALL_THRESHOLD_MS = 1000;
17
- const MIN_STREAM_UPDATES = 2;
18
- const MIN_MEASUREMENT_MS = 200;
19
17
 
20
18
  type TelemetryEvent =
21
19
  | AgentStartEvent
@@ -30,24 +28,16 @@ type AgentMessage = MessageStartEvent["message"];
30
28
  type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
31
29
 
32
30
  interface MessageTiming {
33
- startMs: number;
34
31
  lastUpdateMs: number;
35
32
  firstOutputMs: number | null;
36
- streamUpdateCount: number;
37
33
  inStall: boolean;
38
34
  }
39
35
 
40
- interface MessageMeasurement {
41
- outputTokens: number;
42
- durationMs: number;
43
- }
44
-
45
36
  interface TurnTiming {
46
37
  startMs: number;
47
38
  firstTokenMs: number | null;
48
39
  currentMessage: MessageTiming | null;
49
40
  messages: AssistantMessage[];
50
- measurements: MessageMeasurement[];
51
41
  generationMs: number;
52
42
  stallMs: number;
53
43
  stallCount: number;
@@ -68,11 +58,6 @@ export interface TurnTelemetry {
68
58
  measurementMs: number | null;
69
59
  }
70
60
 
71
- interface MeasuredTurn {
72
- telemetry: TurnTelemetry;
73
- measuredOutputTokens: number;
74
- }
75
-
76
61
  function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
77
62
  return message.role === "assistant";
78
63
  }
@@ -86,7 +71,7 @@ export class TurnTelemetryTracker {
86
71
  private readonly now: () => number;
87
72
  private turn: TurnTiming | undefined;
88
73
  private agentStartMs: number | null = null;
89
- private agentTurns: MeasuredTurn[] = [];
74
+ private agentTurns: TurnTelemetry[] = [];
90
75
 
91
76
  constructor(now: () => number = () => performance.now()) {
92
77
  this.now = now;
@@ -127,7 +112,6 @@ export class TurnTelemetryTracker {
127
112
  firstTokenMs: null,
128
113
  currentMessage: null,
129
114
  messages: [],
130
- measurements: [],
131
115
  generationMs: 0,
132
116
  stallMs: 0,
133
117
  stallCount: 0,
@@ -138,10 +122,8 @@ export class TurnTelemetryTracker {
138
122
  if (!this.turn || !isAssistantMessage(message)) return;
139
123
  const now = this.now();
140
124
  this.turn.currentMessage = {
141
- startMs: now,
142
125
  lastUpdateMs: now,
143
126
  firstOutputMs: null,
144
- streamUpdateCount: 0,
145
127
  inStall: false,
146
128
  };
147
129
  }
@@ -163,13 +145,10 @@ export class TurnTelemetryTracker {
163
145
  if (current.firstOutputMs === null) {
164
146
  current.firstOutputMs = now;
165
147
  turn.firstTokenMs ??= now;
166
- current.streamUpdateCount = 1;
167
148
  current.lastUpdateMs = now;
168
149
  return;
169
150
  }
170
151
 
171
- current.streamUpdateCount++;
172
-
173
152
  const gap = now - current.lastUpdateMs;
174
153
  if (gap >= STALL_THRESHOLD_MS) {
175
154
  if (!current.inStall) turn.stallCount++;
@@ -188,30 +167,22 @@ export class TurnTelemetryTracker {
188
167
  const current = turn.currentMessage;
189
168
  if (current) {
190
169
  const endMs = this.now();
191
- turn.generationMs += endMs - current.startMs;
170
+ turn.generationMs = endMs - turn.startMs;
192
171
  if (current.firstOutputMs === null && message.usage.output > 0) {
193
172
  turn.firstTokenMs ??= endMs;
194
173
  }
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 });
202
- }
203
174
  turn.currentMessage = null;
204
175
  }
205
176
  turn.messages.push(message);
206
177
  }
207
178
 
208
179
  private endTurnAndCollect(): TurnTelemetry | undefined {
209
- const result = this.endTurn();
210
- if (result && this.agentStartMs !== null) this.agentTurns.push(result);
211
- return result?.telemetry;
180
+ const telemetry = this.endTurn();
181
+ if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
182
+ return telemetry;
212
183
  }
213
184
 
214
- private endTurn(): MeasuredTurn | undefined {
185
+ private endTurn(): TurnTelemetry | undefined {
215
186
  const turn = this.turn;
216
187
  this.turn = undefined;
217
188
  if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
@@ -231,32 +202,27 @@ export class TurnTelemetryTracker {
231
202
  throw new Error("Invalid assistant usage in turn telemetry");
232
203
  }
233
204
 
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;
205
+ const measurementMs = outputTokens > 0 && turn.generationMs > 0 ? turn.generationMs : null;
237
206
  const tps = measurementMs === null
238
207
  ? null
239
- : round(measuredOutputTokens / (measurementMs / 1000), 1);
208
+ : round(outputTokens / (measurementMs / 1000), 1);
240
209
  const validCost = Number.isFinite(costUsd) && costUsd > 0;
241
210
  const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
242
211
  return {
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,
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,
260
226
  };
261
227
  }
262
228
 
@@ -267,29 +233,28 @@ export class TurnTelemetryTracker {
267
233
  this.agentTurns = [];
268
234
  if (startMs === null || turns.length === 0) return;
269
235
 
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;
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;
279
244
  const tps = measurementMs === null
280
245
  ? null
281
- : round(measuredOutputTokens / (measurementMs / 1000), 1);
246
+ : round(outputTokens / (measurementMs / 1000), 1);
282
247
  const validRate = costUsd > 0 && totalTokens > 0;
283
248
  return {
284
249
  tps,
285
- ttftMs: turns[0]!.telemetry.ttftMs,
250
+ ttftMs: turns[0]!.ttftMs,
286
251
  totalMs: this.now() - startMs,
287
252
  inputTokens,
288
253
  outputTokens,
289
254
  stallMs,
290
255
  stallCount,
291
256
  rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
292
- generationMs: turns.reduce((sum, turn) => sum + turn.telemetry.generationMs, 0),
257
+ generationMs,
293
258
  totalTokens,
294
259
  costUsd,
295
260
  measurementMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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": {