pi-open-tui 0.3.12 → 0.3.13

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
@@ -75,6 +75,7 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
75
75
  "context": true,
76
76
  "tokens": true,
77
77
  "cost": true,
78
+ "liveTps": true,
78
79
  "extensionStatuses": true,
79
80
  "capitalizeProviderName": true,
80
81
  "nerdIconSpacing": false
@@ -122,6 +123,8 @@ After each complete agent run, pi-open-tui shows one transient result. Tool-call
122
123
 
123
124
  TPS is calculated from all provider-reported assistant output tokens divided by the total generation time across the run. Timing starts at `turn_start` and ends at the assistant `message_end`, so it includes TTFT, hidden reasoning, buffering, and stalls; tool execution between turns is excluded. Runs without output tokens or measurable generation time show `TPS —`.
124
125
 
126
+ While a response streams, the working timer also shows the current estimated speed, for example `working 12.3s · ~54 tok/s`. The `~` marks it as an estimate: it is the rate over the last 2 seconds of streamed text, thinking, and tool-call arguments, estimated at about 4 characters per token for Latin text and code and 1 token per CJK character. It drops toward zero during stalls and stays dimmed at its last value during tool execution and between messages. It is controlled by **Footer → Live TPS** (`footerSegments.liveTps`), independently of the telemetry notification. It disappears when the run ends; the exact TPS above is the authoritative value.
127
+
125
128
  The `$ / M` value is the model's list-price rate from `usage.cost.total`, not the cumulative session cost shown in the footer. Every telemetry field can be toggled from the **Telemetry** tab.
126
129
 
127
130
  ## Thinking peek
package/README.zh-CN.md CHANGED
@@ -75,6 +75,7 @@ pi -e npm:pi-open-tui
75
75
  "context": true,
76
76
  "tokens": true,
77
77
  "cost": true,
78
+ "liveTps": true,
78
79
  "extensionStatuses": true,
79
80
  "capitalizeProviderName": true,
80
81
  "nerdIconSpacing": false
@@ -122,6 +123,8 @@ pi -e npm:pi-open-tui
122
123
 
123
124
  TPS 的计算方式是:将本次运行中服务商报告的全部 Assistant 输出 Token,除以各个生成轮次的总耗时。计时范围从 `turn_start` 到 Assistant 的 `message_end`,包含 TTFT、隐藏推理、缓冲和停顿,但不包含轮次之间的工具执行时间。没有输出 Token 或无法测得生成时间时,会显示 `TPS —`。
124
125
 
126
+ 回复流式输出时,工作计时器还会显示当前的估算速度,例如 `working 12.3s · ~54 tok/s`。`~` 表示估算值:它是最近 2 秒内流式输出的正文、思考内容和工具调用参数的速率,按拉丁文本和代码约 4 个字符 1 个 Token、中日韩字符每字 1 个 Token 估算。停顿时数值会逐渐降向零;工具执行期间和消息之间以暗色保留上一个值。由**底栏 → 实时 TPS**(`footerSegments.liveTps`)控制,与遥测通知的开关互不影响。运行结束后不再显示,以上面的精确 TPS 为准。
127
+
125
128
  `$ / M` 表示根据 `usage.cost.total` 得到的模型标价速率,不是底栏中的会话累计费用。所有遥测字段都可以在**遥测**页单独开关。
126
129
 
127
130
  ## 思考预览
@@ -20,6 +20,7 @@ export interface FooterSegments {
20
20
  context: boolean;
21
21
  tokens: boolean;
22
22
  cost: boolean;
23
+ liveTps: boolean;
23
24
  extensionStatuses: boolean;
24
25
  capitalizeProviderName: boolean;
25
26
  nerdIconSpacing: boolean;
@@ -71,6 +72,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
71
72
  context: true,
72
73
  tokens: true,
73
74
  cost: true,
75
+ liveTps: true,
74
76
  extensionStatuses: true,
75
77
  capitalizeProviderName: true,
76
78
  nerdIconSpacing: false,
@@ -6,6 +6,7 @@ import type { IconGlyphs } from "./icons.ts";
6
6
  import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
7
7
  import type { GitStatus } from "./git.ts";
8
8
  import type { RuntimeInfo } from "./runtime.ts";
9
+ import type { LiveTps } from "./telemetry.ts";
9
10
  import {
10
11
  alignRight,
11
12
  basenamePath,
@@ -224,6 +225,7 @@ function renderExtensionStatusLines(
224
225
  export interface FooterHooks {
225
226
  setRequestRender: (fn: (() => void) | undefined) => void;
226
227
  scheduleGitRefresh: () => void;
228
+ getLiveTps?: () => LiveTps | null;
227
229
  }
228
230
 
229
231
  interface FooterDataLike {
@@ -283,6 +285,7 @@ function renderFooterContent(
283
285
  theme: Theme,
284
286
  footerData: FooterDataLike,
285
287
  width: number,
288
+ getLiveTps: () => LiveTps | null,
286
289
  ): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
287
290
  if (width <= 0) {
288
291
  return {
@@ -364,7 +367,14 @@ function renderFooterContent(
364
367
  }
365
368
  const timerSeg = renderTimerSegment(theme, state, glyphs);
366
369
  if (timerSeg) {
367
- leftParts.push({ text: timerSeg, priority: 1 });
370
+ const liveTps = state.workingSince !== undefined && segments.liveTps
371
+ ? getLiveTps()
372
+ : null;
373
+ const text = liveTps === null
374
+ ? timerSeg
375
+ : `${timerSeg} ${theme.fg("dim", "·")} ${theme.fg(liveTps.stale ? "dim" : "accent", `${glyphs.speed} ~${Math.round(liveTps.tps)} tok/s`)}`;
376
+ // Narrow widths shed the live TPS before the timer itself.
377
+ leftParts.push({ text, compactText: liveTps === null ? undefined : timerSeg, priority: 1 });
368
378
  inlineTopRightParts.push(leftParts.at(-1)!);
369
379
  }
370
380
 
@@ -443,6 +453,7 @@ export function installFooter(
443
453
  theme,
444
454
  footerData,
445
455
  width,
456
+ hooks.getLiveTps ?? (() => null),
446
457
  );
447
458
 
448
459
  renderInline = (width) => getContent(width).inlineLines;
@@ -77,11 +77,13 @@ export async function readGitStatus(
77
77
  status.branch = undefined;
78
78
  status.commit = { oid: null, detached: true, tag: null };
79
79
  } else {
80
- const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
80
+ const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(.*)\])?$/);
81
81
  if (branchMatch) {
82
82
  status.branch = branchMatch[1];
83
- if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
84
- if (branchMatch[3] === "behind") status.behind = parseInt(branchMatch[4]!, 10);
83
+ const ahead = branchMatch[3]?.match(/\bahead (\d+)\b/);
84
+ const behind = branchMatch[3]?.match(/\bbehind (\d+)\b/);
85
+ if (ahead) status.ahead = parseInt(ahead[1]!, 10);
86
+ if (behind) status.behind = parseInt(behind[1]!, 10);
85
87
  }
86
88
  }
87
89
  continue;
@@ -103,7 +105,7 @@ export async function readGitStatus(
103
105
  const x = line[0]!;
104
106
  const y = line[1]!;
105
107
 
106
- if (x === "U" || y === "U" || (x === "C" && y === "C")) status.conflicted++;
108
+ if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) status.conflicted++;
107
109
  else if (x === "?" && y === "?") status.untracked++;
108
110
  else if (x === "R") status.renamed++;
109
111
  else if (x === "D" || y === "D") status.deleted++;
@@ -143,6 +143,7 @@ export default function (pi: ExtensionAPI) {
143
143
  scheduleGitRefresh: () => {
144
144
  void scheduleGitRefresh(ctx);
145
145
  },
146
+ getLiveTps: () => turnTelemetry.getLiveTps(),
146
147
  },
147
148
  );
148
149
  cleanupFooter = footer.cleanup;
@@ -46,6 +46,7 @@ const COPY = {
46
46
  extensionStatuses: "Extension status line",
47
47
  capitalizeProviderName: "Capitalize provider name",
48
48
  nerdIconSpacing: "Extra space after Nerd icons",
49
+ liveTps: "Live TPS",
49
50
  totalDuration: "Total duration",
50
51
  tokenCounts: "Run tokens",
51
52
  stallDetails: "Stall details",
@@ -84,6 +85,7 @@ const COPY = {
84
85
  extensionStatuses: "扩展状态行",
85
86
  capitalizeProviderName: "提供商名称首字母大写",
86
87
  nerdIconSpacing: "Nerd 图标后额外留空",
88
+ liveTps: "实时 TPS",
87
89
  totalDuration: "总耗时",
88
90
  tokenCounts: "单次运行 Token",
89
91
  stallDetails: "停顿详情",
@@ -185,6 +187,7 @@ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
185
187
  { id: "context", label: copy.labels.context, currentValue: flag(segs.context) },
186
188
  { id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
187
189
  { id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
190
+ { id: "liveTps", label: copy.labels.liveTps, currentValue: flag(segs.liveTps) },
188
191
  { id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
189
192
  { id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
190
193
  { id: "nerdIconSpacing", label: copy.labels.nerdIconSpacing, currentValue: flag(segs.nerdIconSpacing) },
@@ -14,6 +14,10 @@ import { resolveGlyphs } from "./icons.ts";
14
14
  import { finiteOrZero, fmtTokens, formatDuration, formatInputBreakdown } from "./utils.ts";
15
15
 
16
16
  const STALL_THRESHOLD_MS = 1000;
17
+ // Live TPS is the rate over this trailing window, so it tracks current speed.
18
+ const LIVE_TPS_WINDOW_MS = 2000;
19
+ // CJK/kana/hangul/fullwidth: roughly one token per character in common tokenizers.
20
+ const CJK_CHARS = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
17
21
 
18
22
  type TelemetryEvent =
19
23
  | AgentStartEvent
@@ -31,6 +35,9 @@ interface MessageTiming {
31
35
  lastUpdateMs: number;
32
36
  firstOutputMs: number | null;
33
37
  inStall: boolean;
38
+ estimatedTokens: number;
39
+ /** Cumulative estimate per delta; keeps one sample before the window as baseline. */
40
+ samples: { ms: number; tokens: number }[];
34
41
  }
35
42
 
36
43
  interface TurnTiming {
@@ -59,10 +66,22 @@ export interface TurnTelemetry {
59
66
  measurementMs: number | null;
60
67
  }
61
68
 
69
+ export interface LiveTps {
70
+ tps: number;
71
+ /** True between messages, when the value is the previous message's last rate. */
72
+ stale: boolean;
73
+ }
74
+
62
75
  function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
63
76
  return message.role === "assistant";
64
77
  }
65
78
 
79
+ /** ~4 chars/token for Latin text and code, ~1 token per CJK character. */
80
+ export function estimateDeltaTokens(delta: string): number {
81
+ const cjk = delta.match(CJK_CHARS)?.length ?? 0;
82
+ return cjk + (delta.length - cjk) / 4;
83
+ }
84
+
66
85
  function round(value: number, decimals: number): number {
67
86
  const factor = 10 ** decimals;
68
87
  return Math.round(value * factor) / factor;
@@ -73,17 +92,41 @@ export class TurnTelemetryTracker {
73
92
  private turn: TurnTiming | undefined;
74
93
  private agentStartMs: number | null = null;
75
94
  private agentTurns: TurnTelemetry[] = [];
95
+ private lastLiveTps: number | null = null;
76
96
 
77
97
  constructor(now: () => number = () => performance.now()) {
78
98
  this.now = now;
79
99
  }
80
100
 
101
+ /**
102
+ * Estimated output rate of the streaming message over the trailing window.
103
+ * Between messages of a run (tools, TTFT) the last rate is kept as `stale`.
104
+ */
105
+ getLiveTps(): LiveTps | null {
106
+ const tps = this.windowTps();
107
+ if (tps !== null) return { tps, stale: false };
108
+ return this.lastLiveTps === null ? null : { tps: this.lastLiveTps, stale: true };
109
+ }
110
+
111
+ private windowTps(): number | null {
112
+ const samples = this.turn?.currentMessage?.samples;
113
+ if (!samples || samples.length < 2) return null;
114
+ const first = samples[0]!;
115
+ const now = this.now();
116
+ // Samples are only pruned on new deltas, so a stall longer than the window means no recent output.
117
+ if (now - samples.at(-1)!.ms >= LIVE_TPS_WINDOW_MS) return 0;
118
+ const elapsedMs = now - first.ms;
119
+ if (elapsedMs <= 0) return null;
120
+ return round((samples.at(-1)!.tokens - first.tokens) / (elapsedMs / 1000), 1);
121
+ }
122
+
81
123
  handle(event: TelemetryEvent): TurnTelemetry | undefined {
82
124
  switch (event.type) {
83
125
  case "agent_start":
84
126
  if (this.agentStartMs === null) {
85
127
  this.agentStartMs = this.now();
86
128
  this.agentTurns = [];
129
+ this.lastLiveTps = null;
87
130
  }
88
131
  return;
89
132
  case "agent_settled":
@@ -126,6 +169,8 @@ export class TurnTelemetryTracker {
126
169
  lastUpdateMs: now,
127
170
  firstOutputMs: null,
128
171
  inStall: false,
172
+ estimatedTokens: 0,
173
+ samples: [],
129
174
  };
130
175
  }
131
176
 
@@ -143,6 +188,11 @@ export class TurnTelemetryTracker {
143
188
  if (!turn || !current || !isAssistantMessage(message)) return;
144
189
 
145
190
  const now = this.now();
191
+ current.estimatedTokens += estimateDeltaTokens(streamEvent.delta);
192
+ const samples = current.samples;
193
+ samples.push({ ms: now, tokens: current.estimatedTokens });
194
+ while (samples.length > 2 && samples[1]!.ms <= now - LIVE_TPS_WINDOW_MS) samples.shift();
195
+
146
196
  if (current.firstOutputMs === null) {
147
197
  current.firstOutputMs = now;
148
198
  turn.firstTokenMs ??= now;
@@ -172,6 +222,7 @@ export class TurnTelemetryTracker {
172
222
  if (current.firstOutputMs === null && finiteOrZero(message.usage?.output) > 0) {
173
223
  turn.firstTokenMs ??= endMs;
174
224
  }
225
+ this.lastLiveTps = this.windowTps() ?? this.lastLiveTps;
175
226
  turn.currentMessage = null;
176
227
  }
177
228
  turn.messages.push(message);
@@ -234,6 +285,7 @@ export class TurnTelemetryTracker {
234
285
  const turns = this.agentTurns;
235
286
  this.agentStartMs = null;
236
287
  this.agentTurns = [];
288
+ this.lastLiveTps = null;
237
289
  if (startMs === null || turns.length === 0) return;
238
290
 
239
291
  const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
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.",
5
5
  "type": "module",
6
6
  "repository": {