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 +3 -0
- package/README.zh-CN.md +3 -0
- package/extensions/open-tui/config.ts +2 -0
- package/extensions/open-tui/editor.ts +89 -15
- package/extensions/open-tui/footer.ts +12 -1
- package/extensions/open-tui/git.ts +6 -4
- package/extensions/open-tui/index.ts +6 -21
- package/extensions/open-tui/settings-command.ts +3 -0
- package/extensions/open-tui/telemetry.ts +52 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
345
|
-
|
|
416
|
+
if (!hiddenThinkingTarget) {
|
|
417
|
+
hiddenThinkingTarget = findLatestHiddenThinkingLabel(tui);
|
|
418
|
+
patchHiddenThinkingRender(hiddenThinkingTarget);
|
|
419
|
+
}
|
|
420
|
+
buildHiddenThinkingLabel = build;
|
|
346
421
|
tui.requestRender();
|
|
347
422
|
},
|
|
348
|
-
resetHiddenThinkingLabelTarget
|
|
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
|
-
|
|
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
|
-
|
|
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+\[(
|
|
80
|
+
const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(.*)\])?$/);
|
|
81
81
|
if (branchMatch) {
|
|
82
82
|
status.branch = branchMatch[1];
|
|
83
|
-
|
|
84
|
-
|
|
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 === "
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
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": {
|