pi-open-tui 0.3.12 → 0.3.14

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,
@@ -40,6 +40,8 @@ export interface InlineFooterRenderer {
40
40
 
41
41
  interface HiddenThinkingLabelComponent {
42
42
  children: unknown[];
43
+ outputPad?: unknown;
44
+ render(width: number): string[];
43
45
  setHiddenThinkingLabel(label: string): void;
44
46
  setHideThinkingBlock(hide: boolean): void;
45
47
  updateContent(message: unknown, isStreaming?: boolean): void;
@@ -48,6 +50,7 @@ interface HiddenThinkingLabelComponent {
48
50
  function isHiddenThinkingLabelComponent(value: object): value is HiddenThinkingLabelComponent {
49
51
  const component = value as unknown as Record<string, unknown>;
50
52
  return Array.isArray(component.children)
53
+ && typeof component.render === "function"
51
54
  && typeof component.setHiddenThinkingLabel === "function"
52
55
  && typeof component.setHideThinkingBlock === "function"
53
56
  && typeof component.updateContent === "function";
@@ -71,6 +74,13 @@ function findLatestHiddenThinkingLabel(tui: TUI): HiddenThinkingLabelComponent {
71
74
  return latest;
72
75
  }
73
76
 
77
+ /** Width Pi's hidden-thinking `Text(label, outputPad, 0)` wraps at when its message renders at `width`. */
78
+ function hiddenThinkingTextWidth(width: number, outputPad: unknown): number {
79
+ const pad = typeof outputPad === "number" && Number.isFinite(outputPad) ? Math.max(0, Math.floor(outputPad)) : 1;
80
+ const paddingX = Math.min(pad, Math.max(0, Math.floor((width - 1) / 2)));
81
+ return Math.max(1, width - paddingX * 2);
82
+ }
83
+
74
84
  function removeSoftwareCursor(line: string, cursorMarker = ""): string {
75
85
  return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, (_match, cursor: string) => {
76
86
  const replacement = `${cursorMarker}${cursor}`;
@@ -308,6 +318,42 @@ export class OpenTuiEditor extends CustomEditor {
308
318
  }
309
319
  }
310
320
 
321
+ type StreamWrite = (...args: unknown[]) => boolean;
322
+
323
+ /**
324
+ * Calls `requestRepaint` after each raw write to a TTY stream. Pi's fullscreen renderer only
325
+ * rewrites changed rows, so text other extensions print straight to the terminal would
326
+ * otherwise stay over the editor and footer until a resize.
327
+ */
328
+ export function repaintAfterStreamWrites(
329
+ requestRepaint: () => void,
330
+ stream: NodeJS.WriteStream = process.stderr,
331
+ ): () => void {
332
+ if (!stream.isTTY) return () => {};
333
+ const original = stream.write as unknown as StreamWrite;
334
+ let enabled = true;
335
+ const patched = ((...args: unknown[]) => {
336
+ const callback = typeof args[args.length - 1] === "function"
337
+ ? (args.pop() as (error?: Error | null) => void)
338
+ : undefined;
339
+ // Repaint from the write callback so the redraw lands after the stray text.
340
+ return original.call(stream, ...args, (error?: Error | null) => {
341
+ try {
342
+ if (enabled) requestRepaint();
343
+ } catch {
344
+ // A failed repaint must not break the caller's write; logging it here would recurse.
345
+ }
346
+ callback?.(error);
347
+ });
348
+ }) as typeof stream.write;
349
+ stream.write = patched;
350
+ return () => {
351
+ enabled = false;
352
+ // A later wrapper may still delegate to ours; leave it as a passthrough then.
353
+ if (stream.write === patched) stream.write = original as unknown as typeof stream.write;
354
+ };
355
+ }
356
+
311
357
  export function installEditor(
312
358
  _pi: ExtensionAPI,
313
359
  ctx: ExtensionContext,
@@ -319,41 +365,69 @@ export function installEditor(
319
365
  let previousHardwareCursor: boolean | undefined;
320
366
  let currentCursorStyle = cursorStyle;
321
367
  let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
368
+ let restoreHiddenThinkingRender: (() => void) | undefined;
369
+ let buildHiddenThinkingLabel: ((width: number) => string) | undefined;
322
370
  const getActiveTui = (): TUI => {
323
371
  if (!activeTui) throw new Error("Open TUI editor is not mounted");
324
372
  return activeTui;
325
373
  };
374
+ const resetHiddenThinkingTarget = (): void => {
375
+ restoreHiddenThinkingRender?.();
376
+ restoreHiddenThinkingRender = undefined;
377
+ hiddenThinkingTarget = undefined;
378
+ buildHiddenThinkingLabel = undefined;
379
+ };
380
+ // Build the label at the width Pi actually renders the message with; layout
381
+ // extensions (e.g. pi-sidebar) can make it much narrower than the terminal.
382
+ const patchHiddenThinkingRender = (target: HiddenThinkingLabelComponent): void => {
383
+ const originalRender = target.render;
384
+ let lastWidth: number | undefined;
385
+ let lastBuild: ((width: number) => string) | undefined;
386
+ target.render = function (this: HiddenThinkingLabelComponent, width: number): string[] {
387
+ const build = buildHiddenThinkingLabel;
388
+ const textWidth = hiddenThinkingTextWidth(width, this.outputPad);
389
+ if (build && (build !== lastBuild || textWidth !== lastWidth)) {
390
+ lastBuild = build;
391
+ lastWidth = textWidth;
392
+ this.setHiddenThinkingLabel(build(textWidth));
393
+ }
394
+ return originalRender.call(this, width);
395
+ };
396
+ restoreHiddenThinkingRender = () => {
397
+ target.render = originalRender;
398
+ };
399
+ };
400
+ // Only the fullscreen renderer clears and rewrites the whole screen on a forced render.
401
+ const restoreStderr = repaintAfterStreamWrites(() => {
402
+ if (activeTui?.mode === "fullscreen") activeTui.requestRender(true);
403
+ });
326
404
 
327
405
  ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
328
406
  activeTui = tui;
329
- hiddenThinkingTarget = undefined;
407
+ resetHiddenThinkingTarget();
330
408
  previousHardwareCursor = tui.getShowHardwareCursor();
331
409
  activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
332
410
  return activeEditor;
333
411
  });
334
412
  return {
335
- getViewportWidth(): number {
336
- const columns = getActiveTui().terminal.columns;
337
- if (typeof columns !== "number" || !Number.isFinite(columns)) {
338
- throw new Error("Open TUI editor terminal has an invalid width");
339
- }
340
- return Math.max(1, Math.floor(columns));
341
- },
342
- setLatestHiddenThinkingLabel(label: string): void {
413
+ /** `build` receives the hidden-thinking text width and runs again whenever that width changes. */
414
+ setLatestHiddenThinkingLabel(build: (width: number) => string): void {
343
415
  const tui = getActiveTui();
344
- hiddenThinkingTarget ??= findLatestHiddenThinkingLabel(tui);
345
- hiddenThinkingTarget.setHiddenThinkingLabel(label);
416
+ if (!hiddenThinkingTarget) {
417
+ hiddenThinkingTarget = findLatestHiddenThinkingLabel(tui);
418
+ patchHiddenThinkingRender(hiddenThinkingTarget);
419
+ }
420
+ buildHiddenThinkingLabel = build;
346
421
  tui.requestRender();
347
422
  },
348
- resetHiddenThinkingLabelTarget(): void {
349
- hiddenThinkingTarget = undefined;
350
- },
423
+ resetHiddenThinkingLabelTarget: resetHiddenThinkingTarget,
351
424
  setCursorStyle(nextCursorStyle: CursorStyle): void {
352
425
  currentCursorStyle = nextCursorStyle;
353
426
  activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
354
427
  },
355
428
  cleanup(): void {
356
- hiddenThinkingTarget = undefined;
429
+ restoreStderr();
430
+ resetHiddenThinkingTarget();
357
431
  ctx.ui.setEditorComponent(undefined);
358
432
  if (activeTui) {
359
433
  if (currentCursorStyle !== "block") activeTui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
@@ -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++;
@@ -24,9 +24,6 @@ import {
24
24
  } from "./peek.ts";
25
25
 
26
26
  type PendingUiChange = "install" | "uninstall";
27
- const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
28
- // Pi's fullscreen transcript can reserve one more column for its scrollbar.
29
- const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
30
27
 
31
28
  export function getPendingUiChange(enabled: boolean, active: boolean): PendingUiChange | undefined {
32
29
  if (enabled === active) return undefined;
@@ -78,29 +75,16 @@ export default function (pi: ExtensionAPI) {
78
75
  /** Pi decides whether this label is visible; our toggle only controls updates. */
79
76
  const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
80
77
 
81
- /** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
82
- const hiddenThinkingLabelWidth = (): number => {
83
- if (!editor) throw new Error("Open TUI editor is not installed");
84
- return Math.max(
85
- 1,
86
- editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
87
- );
88
- };
89
-
90
78
  const setPeekLabel = (ctx?: ExtensionContext): void => {
91
79
  if (!isPeekEnabled() || peek.phase === "idle") return;
92
80
  const target = ctx ?? lastCtx;
93
81
  if (!target || !isTuiContext(target)) return;
94
82
  if (!editor) throw new Error("Open TUI editor is not installed");
95
- editor.setLatestHiddenThinkingLabel(
96
- buildPeekLabel(
97
- peek,
98
- peekFrame,
99
- resolveGlyphs(config.icons.mode),
100
- hiddenThinkingLabelWidth(),
101
- config.thinkingPeek.lines,
102
- ),
103
- );
83
+ const snapshot = { ...peek };
84
+ const frame = peekFrame;
85
+ const glyphs = resolveGlyphs(config.icons.mode);
86
+ const lines = config.thinkingPeek.lines;
87
+ editor.setLatestHiddenThinkingLabel((width) => buildPeekLabel(snapshot, frame, glyphs, width, lines));
104
88
  peekLabelActive = true;
105
89
  };
106
90
 
@@ -143,6 +127,7 @@ export default function (pi: ExtensionAPI) {
143
127
  scheduleGitRefresh: () => {
144
128
  void scheduleGitRefresh(ctx);
145
129
  },
130
+ getLiveTps: () => turnTelemetry.getLiveTps(),
146
131
  },
147
132
  );
148
133
  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.14",
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": {