pi-open-tui 0.2.4 → 0.2.6
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
|
@@ -12,8 +12,9 @@ A polished TUI for [Pi](https://pi.dev) coding agent. Combines the best of pi-ha
|
|
|
12
12
|
- **60+ runtime detection** — Node, Rust, Go, Python, Ruby, Java, Swift, Kotlin, C/C++, Deno, Bun, and many more
|
|
13
13
|
- **Git status** — branch, ahead/behind, modified/untracked/staged/stashed, detached HEAD commit hash + tag
|
|
14
14
|
- **Working timer** — live elapsed time while the agent is working, done duration when finished
|
|
15
|
+
- **Turn telemetry** — generation speed, TTFT, stalls, tokens, and list-price rate after each complete agent run
|
|
15
16
|
- **Zero prototype patches** — uses public Pi APIs (setHeader/setFooter/setEditorComponent), safe across Pi updates
|
|
16
|
-
- **Interactive settings UI** — `/open-tui` opens a tabbed settings dialog (Features / Icons / Segments)
|
|
17
|
+
- **Interactive settings UI** — `/open-tui` opens a tabbed settings dialog (Features / Icons / Segments / Telemetry)
|
|
17
18
|
|
|
18
19
|
## Install
|
|
19
20
|
|
|
@@ -46,6 +47,15 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
|
|
|
46
47
|
"context": true,
|
|
47
48
|
"tokens": true,
|
|
48
49
|
"cost": true
|
|
50
|
+
},
|
|
51
|
+
"telemetry": {
|
|
52
|
+
"enabled": true,
|
|
53
|
+
"tps": true,
|
|
54
|
+
"ttft": true,
|
|
55
|
+
"duration": true,
|
|
56
|
+
"tokens": true,
|
|
57
|
+
"stalls": true,
|
|
58
|
+
"cost": true
|
|
49
59
|
}
|
|
50
60
|
}
|
|
51
61
|
```
|
|
@@ -53,6 +63,18 @@ Run `/open-tui` to open the interactive settings UI. Configuration is stored at
|
|
|
53
63
|
- `icons.mode`: `auto` (detect Nerd Font), `nerd` (force Nerd Font glyphs), or `ascii` (plain fallbacks)
|
|
54
64
|
- `footerSegments.gitCommit`: shows short hash + tag on detached HEAD (off by default)
|
|
55
65
|
|
|
66
|
+
## Turn telemetry
|
|
67
|
+
|
|
68
|
+
After each complete agent run, open-tui shows one transient notification. Tool-call turns are aggregated into that single result:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
> TPS 42.5 tok/s | ~ TTFT 1.2s | + 29.7s | ↓ in 567 | ↑ out 1.2k | ! stall 1x / 4.3s | $ $3.60/M
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The notification uses the footer's icon mode and semantic theme colors. Configure its master switch and individual TPS, TTFT, duration, token, stall, and cost segments from the **Telemetry** tab in `/open-tui`.
|
|
75
|
+
|
|
76
|
+
TPS excludes time-to-first-token, detected stalls, and tool execution between messages. In a multi-turn agent run, it is weighted from the turns with measurable streaming spans; an unmeasurable tool turn no longer hides a measurable final response. A rate that cannot be identified reliably is shown as `TPS —`. The `stall` segment shows occurrence count followed by accumulated duration. The optional `$ / M` value uses the model's list-price `usage.cost.total`; it is not the session's cumulative cost shown in the footer.
|
|
77
|
+
|
|
56
78
|
## Local development
|
|
57
79
|
|
|
58
80
|
```bash
|
|
@@ -70,5 +92,6 @@ This project builds on the work of several Pi community packages:
|
|
|
70
92
|
- **[pi-haiku](https://github.com/nnocte/pi-haiku)** — the 2-line footer structure (location+model · timer+context) and working-timer pattern
|
|
71
93
|
- **[pi-claude-code-tui](https://github.com/Phoobobo/pi-claude-code-tui)** — the 16-frame animated Pi logo and rounded editor border technique
|
|
72
94
|
- **[pi-zentui](https://github.com/lmilojevicc/pi-zentui)** — the Starship-style footer segments (git status icons, runtime detection, context gauge), generation-based session lifecycle, and interactive settings UI pattern
|
|
95
|
+
- **[pi-tps](https://github.com/monotykamary/pi-tps)** — the turn timing, stall detection, and conservative TPS measurement approach
|
|
73
96
|
|
|
74
97
|
The animated logo frames are derived from `pi-claude-code-tui`, which in turn derive from Pi's official install script (`pi.dev/install.sh`). The runtime detection list and git porcelain parsing borrow structure from `pi-zentui`.
|
|
@@ -16,12 +16,23 @@ export interface FooterSegments {
|
|
|
16
16
|
cost: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export interface TelemetryConfig {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
tps: boolean;
|
|
22
|
+
ttft: boolean;
|
|
23
|
+
duration: boolean;
|
|
24
|
+
tokens: boolean;
|
|
25
|
+
stalls: boolean;
|
|
26
|
+
cost: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export interface OpenTuiConfig {
|
|
20
30
|
enabled: boolean;
|
|
21
31
|
icons: {
|
|
22
32
|
mode: IconMode;
|
|
23
33
|
};
|
|
24
34
|
footerSegments: FooterSegments;
|
|
35
|
+
telemetry: TelemetryConfig;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
@@ -39,6 +50,15 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
|
39
50
|
tokens: true,
|
|
40
51
|
cost: true,
|
|
41
52
|
},
|
|
53
|
+
telemetry: {
|
|
54
|
+
enabled: true,
|
|
55
|
+
tps: true,
|
|
56
|
+
ttft: true,
|
|
57
|
+
duration: true,
|
|
58
|
+
tokens: true,
|
|
59
|
+
stalls: true,
|
|
60
|
+
cost: true,
|
|
61
|
+
},
|
|
42
62
|
};
|
|
43
63
|
|
|
44
64
|
export function getConfigPath(): string {
|
|
@@ -12,6 +12,9 @@ export interface IconGlyphs {
|
|
|
12
12
|
output: string;
|
|
13
13
|
cacheHit: string;
|
|
14
14
|
cost: string;
|
|
15
|
+
speed: string;
|
|
16
|
+
latency: string;
|
|
17
|
+
stall: string;
|
|
15
18
|
extensions: string;
|
|
16
19
|
ahead: string;
|
|
17
20
|
behind: string;
|
|
@@ -37,6 +40,9 @@ const NERD_GLYPHS: IconGlyphs = {
|
|
|
37
40
|
output: "",
|
|
38
41
|
cacheHit: "",
|
|
39
42
|
cost: "",
|
|
43
|
+
speed: "",
|
|
44
|
+
latency: "",
|
|
45
|
+
stall: "",
|
|
40
46
|
extensions: "",
|
|
41
47
|
ahead: "↑",
|
|
42
48
|
behind: "↓",
|
|
@@ -61,10 +67,13 @@ const ASCII_GLYPHS: IconGlyphs = {
|
|
|
61
67
|
context: "%",
|
|
62
68
|
model: "M",
|
|
63
69
|
thinking: "~",
|
|
64
|
-
input: "
|
|
65
|
-
output: "
|
|
70
|
+
input: "↓",
|
|
71
|
+
output: "↑",
|
|
66
72
|
cacheHit: "c",
|
|
67
73
|
cost: "$",
|
|
74
|
+
speed: ">",
|
|
75
|
+
latency: "~",
|
|
76
|
+
stall: "!",
|
|
68
77
|
extensions: "&",
|
|
69
78
|
ahead: "^",
|
|
70
79
|
behind: "v",
|
|
@@ -7,6 +7,7 @@ import { readGitStatus } from "./git.ts";
|
|
|
7
7
|
import { readRuntimeInfo } from "./runtime.ts";
|
|
8
8
|
import { SessionLifecycle } from "./session-lifecycle.ts";
|
|
9
9
|
import { registerSettingsCommand } from "./settings-command.ts";
|
|
10
|
+
import { formatTurnTelemetry, TurnTelemetryTracker } from "./telemetry.ts";
|
|
10
11
|
import {
|
|
11
12
|
createInitialState,
|
|
12
13
|
getModelMeta,
|
|
@@ -43,6 +44,7 @@ function isTuiContext(ctx: ExtensionContext): boolean {
|
|
|
43
44
|
export default function (pi: ExtensionAPI) {
|
|
44
45
|
const sessionLifecycle = new SessionLifecycle();
|
|
45
46
|
const state: FooterState = createInitialState();
|
|
47
|
+
const turnTelemetry = new TurnTelemetryTracker();
|
|
46
48
|
|
|
47
49
|
let config: OpenTuiConfig = structuredClone(DEFAULT_CONFIG);
|
|
48
50
|
let active = false;
|
|
@@ -175,7 +177,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
175
177
|
lastCtx = undefined;
|
|
176
178
|
});
|
|
177
179
|
|
|
178
|
-
pi.on("agent_start", (
|
|
180
|
+
pi.on("agent_start", (event, _ctx) => {
|
|
181
|
+
turnTelemetry.handle(event);
|
|
179
182
|
if (!sessionLifecycle.isCurrent()) return;
|
|
180
183
|
state.workingSince = Date.now();
|
|
181
184
|
state.lastDoneIn = undefined;
|
|
@@ -192,6 +195,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
195
|
requestFooterRender?.();
|
|
193
196
|
});
|
|
194
197
|
|
|
198
|
+
pi.on("turn_start", (event) => {
|
|
199
|
+
turnTelemetry.handle(event);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
pi.on("message_start", (event) => {
|
|
203
|
+
turnTelemetry.handle(event);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.on("message_update", (event) => {
|
|
207
|
+
turnTelemetry.handle(event);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.on("tool_execution_start", (event) => {
|
|
211
|
+
turnTelemetry.handle(event);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
pi.on("turn_end", (event) => {
|
|
215
|
+
turnTelemetry.handle(event);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
219
|
+
const telemetry = turnTelemetry.handle(event);
|
|
220
|
+
if (telemetry && config.enabled && config.telemetry.enabled && isTuiContext(ctx)) {
|
|
221
|
+
const message = formatTurnTelemetry(telemetry, ctx.ui.theme, config.telemetry, config.icons.mode);
|
|
222
|
+
if (message) ctx.ui.notify(message, "info");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
195
226
|
pi.on("model_select", (_event, ctx) => {
|
|
196
227
|
refreshInteractiveState(ctx);
|
|
197
228
|
});
|
|
@@ -200,7 +231,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
231
|
refreshInteractiveState(ctx);
|
|
201
232
|
});
|
|
202
233
|
|
|
203
|
-
pi.on("message_end", (
|
|
234
|
+
pi.on("message_end", (event, ctx) => {
|
|
235
|
+
turnTelemetry.handle(event);
|
|
204
236
|
if (!sessionLifecycle.isCurrent()) return;
|
|
205
237
|
invalidateUsageCache();
|
|
206
238
|
refreshInteractiveState(ctx);
|
|
@@ -17,12 +17,13 @@ interface SettingItem {
|
|
|
17
17
|
values: string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
type Tab = "features" | "icons" | "segments";
|
|
20
|
+
type Tab = "features" | "icons" | "segments" | "telemetry";
|
|
21
21
|
|
|
22
22
|
const TABS: { id: Tab; label: string }[] = [
|
|
23
23
|
{ id: "features", label: "Features" },
|
|
24
24
|
{ id: "icons", label: "Icons" },
|
|
25
25
|
{ id: "segments", label: "Segments" },
|
|
26
|
+
{ id: "telemetry", label: "Telemetry" },
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
function toggleSetting(config: OpenTuiConfig, key: keyof OpenTuiConfig["footerSegments"]): OpenTuiConfig {
|
|
@@ -46,6 +47,13 @@ function toggleEnabled(config: OpenTuiConfig): OpenTuiConfig {
|
|
|
46
47
|
return { ...config, enabled: !config.enabled };
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
function toggleTelemetry(config: OpenTuiConfig, key: keyof OpenTuiConfig["telemetry"]): OpenTuiConfig {
|
|
51
|
+
return {
|
|
52
|
+
...config,
|
|
53
|
+
telemetry: { ...config.telemetry, [key]: !config.telemetry[key] },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
function buildFeaturesItems(config: OpenTuiConfig): SettingItem[] {
|
|
50
58
|
return [
|
|
51
59
|
{
|
|
@@ -82,11 +90,25 @@ function buildSegmentsItems(config: OpenTuiConfig): SettingItem[] {
|
|
|
82
90
|
];
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
function buildTelemetryItems(config: OpenTuiConfig): SettingItem[] {
|
|
94
|
+
const telemetry = config.telemetry;
|
|
95
|
+
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"] },
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
function buildItems(tab: Tab, config: OpenTuiConfig): SettingItem[] {
|
|
86
107
|
switch (tab) {
|
|
87
108
|
case "features": return buildFeaturesItems(config);
|
|
88
109
|
case "icons": return buildIconsItems(config);
|
|
89
110
|
case "segments": return buildSegmentsItems(config);
|
|
111
|
+
case "telemetry": return buildTelemetryItems(config);
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
114
|
|
|
@@ -104,6 +126,9 @@ function handleSettingChange(
|
|
|
104
126
|
if (tab === "segments") {
|
|
105
127
|
return toggleSetting(config, itemId as keyof OpenTuiConfig["footerSegments"]);
|
|
106
128
|
}
|
|
129
|
+
if (tab === "telemetry") {
|
|
130
|
+
return toggleTelemetry(config, itemId as keyof OpenTuiConfig["telemetry"]);
|
|
131
|
+
}
|
|
107
132
|
return config;
|
|
108
133
|
}
|
|
109
134
|
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSettledEvent,
|
|
3
|
+
AgentStartEvent,
|
|
4
|
+
MessageEndEvent,
|
|
5
|
+
MessageStartEvent,
|
|
6
|
+
MessageUpdateEvent,
|
|
7
|
+
ToolExecutionStartEvent,
|
|
8
|
+
TurnEndEvent,
|
|
9
|
+
TurnStartEvent,
|
|
10
|
+
Theme,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { IconMode, TelemetryConfig } from "./config.ts";
|
|
13
|
+
import { resolveGlyphs } from "./icons.ts";
|
|
14
|
+
import { fmtTokens, formatDuration } from "./utils.ts";
|
|
15
|
+
|
|
16
|
+
const STALL_THRESHOLD_MS = 500;
|
|
17
|
+
const MIN_STREAM_MS = 1;
|
|
18
|
+
const MIN_STREAM_UPDATES = 5;
|
|
19
|
+
const MIN_INTER_CHUNK_MS = 1;
|
|
20
|
+
const MIN_GENERATION_MS = 200;
|
|
21
|
+
const STALL_DOMINANCE_RATIO = 0.85;
|
|
22
|
+
const MAX_PLAUSIBLE_TPS = 10_000;
|
|
23
|
+
|
|
24
|
+
type TelemetryEvent =
|
|
25
|
+
| AgentStartEvent
|
|
26
|
+
| AgentSettledEvent
|
|
27
|
+
| TurnStartEvent
|
|
28
|
+
| MessageStartEvent
|
|
29
|
+
| MessageUpdateEvent
|
|
30
|
+
| MessageEndEvent
|
|
31
|
+
| ToolExecutionStartEvent
|
|
32
|
+
| TurnEndEvent;
|
|
33
|
+
type AgentMessage = MessageStartEvent["message"];
|
|
34
|
+
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
|
35
|
+
|
|
36
|
+
interface MessageTiming {
|
|
37
|
+
startMs: number;
|
|
38
|
+
lastUpdateMs: number;
|
|
39
|
+
firstUpdateSeen: boolean;
|
|
40
|
+
streamUpdateCount: number;
|
|
41
|
+
firstStreamUpdateMs: number | null;
|
|
42
|
+
lastStreamUpdateMs: number | null;
|
|
43
|
+
inStall: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TurnTiming {
|
|
47
|
+
startMs: number;
|
|
48
|
+
firstTokenMs: number | null;
|
|
49
|
+
currentMessage: MessageTiming | null;
|
|
50
|
+
messages: AssistantMessage[];
|
|
51
|
+
generationMs: number;
|
|
52
|
+
streamMs: number;
|
|
53
|
+
streamIntervals: number;
|
|
54
|
+
streamUpdateCount: number;
|
|
55
|
+
stallMs: number;
|
|
56
|
+
stallCount: number;
|
|
57
|
+
isToolCall: boolean;
|
|
58
|
+
modelKey: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TurnTelemetry {
|
|
62
|
+
tps: number | null;
|
|
63
|
+
ttftMs: number;
|
|
64
|
+
totalMs: number;
|
|
65
|
+
inputTokens: number;
|
|
66
|
+
outputTokens: number;
|
|
67
|
+
stallMs: number;
|
|
68
|
+
stallCount: number;
|
|
69
|
+
rateUsdPerMTokens: number | null;
|
|
70
|
+
generationMs: number;
|
|
71
|
+
totalTokens: number;
|
|
72
|
+
costUsd: number;
|
|
73
|
+
measurementMs: number | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
|
|
77
|
+
return message.role === "assistant";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function round(value: number, decimals: number): number {
|
|
81
|
+
const factor = 10 ** decimals;
|
|
82
|
+
return Math.round(value * factor) / factor;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class TurnTelemetryTracker {
|
|
86
|
+
private readonly now: () => number;
|
|
87
|
+
private readonly tpsCaps = new Map<string, number>();
|
|
88
|
+
private turn: TurnTiming | undefined;
|
|
89
|
+
private agentStartMs: number | null = null;
|
|
90
|
+
private agentTurns: TurnTelemetry[] = [];
|
|
91
|
+
|
|
92
|
+
constructor(now: () => number = () => performance.now()) {
|
|
93
|
+
this.now = now;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
handle(event: TelemetryEvent): TurnTelemetry | undefined {
|
|
97
|
+
switch (event.type) {
|
|
98
|
+
case "agent_start":
|
|
99
|
+
if (this.agentStartMs === null) {
|
|
100
|
+
this.agentStartMs = this.now();
|
|
101
|
+
this.agentTurns = [];
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
case "agent_settled":
|
|
105
|
+
return this.endAgent();
|
|
106
|
+
case "turn_start":
|
|
107
|
+
this.startTurn();
|
|
108
|
+
return;
|
|
109
|
+
case "message_start":
|
|
110
|
+
this.startMessage(event.message);
|
|
111
|
+
return;
|
|
112
|
+
case "message_update":
|
|
113
|
+
this.updateMessage(event);
|
|
114
|
+
return;
|
|
115
|
+
case "message_end":
|
|
116
|
+
this.endMessage(event.message);
|
|
117
|
+
return;
|
|
118
|
+
case "tool_execution_start":
|
|
119
|
+
if (this.turn) this.turn.isToolCall = true;
|
|
120
|
+
return;
|
|
121
|
+
case "turn_end":
|
|
122
|
+
return this.endTurnAndCollect();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private startTurn(): void {
|
|
127
|
+
this.turn = {
|
|
128
|
+
startMs: this.now(),
|
|
129
|
+
firstTokenMs: null,
|
|
130
|
+
currentMessage: null,
|
|
131
|
+
messages: [],
|
|
132
|
+
generationMs: 0,
|
|
133
|
+
streamMs: 0,
|
|
134
|
+
streamIntervals: 0,
|
|
135
|
+
streamUpdateCount: 0,
|
|
136
|
+
stallMs: 0,
|
|
137
|
+
stallCount: 0,
|
|
138
|
+
isToolCall: false,
|
|
139
|
+
modelKey: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private startMessage(message: AgentMessage): void {
|
|
144
|
+
if (!this.turn || !isAssistantMessage(message)) return;
|
|
145
|
+
const now = this.now();
|
|
146
|
+
this.turn.currentMessage = {
|
|
147
|
+
startMs: now,
|
|
148
|
+
lastUpdateMs: now,
|
|
149
|
+
firstUpdateSeen: false,
|
|
150
|
+
streamUpdateCount: 0,
|
|
151
|
+
firstStreamUpdateMs: null,
|
|
152
|
+
lastStreamUpdateMs: null,
|
|
153
|
+
inStall: false,
|
|
154
|
+
};
|
|
155
|
+
this.turn.modelKey ??= `${message.provider}:${message.model}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private updateMessage(event: MessageUpdateEvent): void {
|
|
159
|
+
const turn = this.turn;
|
|
160
|
+
const current = turn?.currentMessage;
|
|
161
|
+
const streamEvent = event.assistantMessageEvent;
|
|
162
|
+
if (
|
|
163
|
+
streamEvent.type !== "text_delta" &&
|
|
164
|
+
streamEvent.type !== "thinking_delta" &&
|
|
165
|
+
streamEvent.type !== "toolcall_delta"
|
|
166
|
+
) return;
|
|
167
|
+
if (streamEvent.delta.length === 0) return;
|
|
168
|
+
const message = event.message;
|
|
169
|
+
if (!turn || !current || !isAssistantMessage(message)) return;
|
|
170
|
+
|
|
171
|
+
const now = this.now();
|
|
172
|
+
if (!current.firstUpdateSeen) {
|
|
173
|
+
current.firstUpdateSeen = true;
|
|
174
|
+
turn.firstTokenMs ??= now;
|
|
175
|
+
current.lastUpdateMs = now;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
current.streamUpdateCount++;
|
|
180
|
+
current.firstStreamUpdateMs ??= now;
|
|
181
|
+
current.lastStreamUpdateMs = now;
|
|
182
|
+
|
|
183
|
+
const gap = now - current.lastUpdateMs;
|
|
184
|
+
if (gap >= STALL_THRESHOLD_MS) {
|
|
185
|
+
if (!current.inStall) turn.stallCount++;
|
|
186
|
+
current.inStall = true;
|
|
187
|
+
turn.stallMs += gap;
|
|
188
|
+
} else {
|
|
189
|
+
current.inStall = false;
|
|
190
|
+
}
|
|
191
|
+
current.lastUpdateMs = now;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private endMessage(message: AgentMessage): void {
|
|
195
|
+
const turn = this.turn;
|
|
196
|
+
if (!turn || !isAssistantMessage(message)) return;
|
|
197
|
+
|
|
198
|
+
const current = turn.currentMessage;
|
|
199
|
+
if (current) {
|
|
200
|
+
turn.generationMs += this.now() - current.startMs;
|
|
201
|
+
turn.streamUpdateCount += current.streamUpdateCount;
|
|
202
|
+
turn.streamIntervals += Math.max(0, current.streamUpdateCount - 1);
|
|
203
|
+
// Sum per-message spans so tool execution between messages never enters TPS.
|
|
204
|
+
if (current.firstStreamUpdateMs !== null && current.lastStreamUpdateMs !== null) {
|
|
205
|
+
turn.streamMs += current.lastStreamUpdateMs - current.firstStreamUpdateMs;
|
|
206
|
+
}
|
|
207
|
+
turn.currentMessage = null;
|
|
208
|
+
}
|
|
209
|
+
turn.messages.push(message);
|
|
210
|
+
turn.modelKey ??= `${message.provider}:${message.model}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private endTurnAndCollect(): TurnTelemetry | undefined {
|
|
214
|
+
const telemetry = this.endTurn();
|
|
215
|
+
if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
|
|
216
|
+
return telemetry;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private endTurn(): TurnTelemetry | undefined {
|
|
220
|
+
const turn = this.turn;
|
|
221
|
+
this.turn = undefined;
|
|
222
|
+
if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
|
|
223
|
+
|
|
224
|
+
const endMs = this.now();
|
|
225
|
+
let inputTokens = 0;
|
|
226
|
+
let outputTokens = 0;
|
|
227
|
+
let totalTokens = 0;
|
|
228
|
+
let costUsd = 0;
|
|
229
|
+
for (const message of turn.messages) {
|
|
230
|
+
inputTokens += message.usage.input;
|
|
231
|
+
outputTokens += message.usage.output;
|
|
232
|
+
totalTokens += message.usage.totalTokens;
|
|
233
|
+
costUsd += message.usage.cost.total;
|
|
234
|
+
}
|
|
235
|
+
if (![inputTokens, outputTokens, totalTokens, costUsd].every(Number.isFinite)) {
|
|
236
|
+
throw new Error("Invalid assistant usage in turn telemetry");
|
|
237
|
+
}
|
|
238
|
+
if (outputTokens <= 0 || !turn.modelKey) return;
|
|
239
|
+
|
|
240
|
+
const streamMs = turn.streamUpdateCount > 0 ? turn.streamMs : null;
|
|
241
|
+
const averageGapMs = turn.streamIntervals > 0 ? turn.streamMs / turn.streamIntervals : 0;
|
|
242
|
+
let tps: number | null = null;
|
|
243
|
+
let measurementMs: number | null = null;
|
|
244
|
+
let primary = false;
|
|
245
|
+
if (
|
|
246
|
+
streamMs !== null &&
|
|
247
|
+
streamMs >= MIN_STREAM_MS &&
|
|
248
|
+
turn.streamUpdateCount >= MIN_STREAM_UPDATES &&
|
|
249
|
+
averageGapMs >= MIN_INTER_CHUNK_MS &&
|
|
250
|
+
turn.stallMs < streamMs &&
|
|
251
|
+
streamMs - turn.stallMs >= MIN_GENERATION_MS &&
|
|
252
|
+
turn.stallMs < streamMs - turn.stallMs
|
|
253
|
+
) {
|
|
254
|
+
measurementMs = streamMs - turn.stallMs;
|
|
255
|
+
tps = round(outputTokens / (measurementMs / 1000), 1);
|
|
256
|
+
primary = true;
|
|
257
|
+
} else if (turn.streamUpdateCount >= 2 && turn.generationMs >= MIN_GENERATION_MS) {
|
|
258
|
+
let activeMs = turn.generationMs - turn.stallMs;
|
|
259
|
+
if (activeMs < MIN_GENERATION_MS || turn.stallMs > turn.generationMs * STALL_DOMINANCE_RATIO) {
|
|
260
|
+
activeMs = turn.generationMs - turn.stallMs / 2;
|
|
261
|
+
}
|
|
262
|
+
measurementMs = Math.max(activeMs, MIN_GENERATION_MS);
|
|
263
|
+
tps = round(outputTokens / (measurementMs / 1000), 1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (tps !== null && tps > MAX_PLAUSIBLE_TPS) {
|
|
267
|
+
tps = null;
|
|
268
|
+
measurementMs = null;
|
|
269
|
+
primary = false;
|
|
270
|
+
}
|
|
271
|
+
if (primary && tps !== null) {
|
|
272
|
+
this.tpsCaps.set(turn.modelKey, Math.max(tps, this.tpsCaps.get(turn.modelKey) ?? 0));
|
|
273
|
+
}
|
|
274
|
+
if (turn.isToolCall && tps !== null) {
|
|
275
|
+
tps = this.tpsCaps.has(turn.modelKey)
|
|
276
|
+
? Math.min(tps, this.tpsCaps.get(turn.modelKey)!)
|
|
277
|
+
: null;
|
|
278
|
+
if (tps === null) measurementMs = null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const validCost = Number.isFinite(costUsd) && costUsd > 0;
|
|
282
|
+
const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
|
|
283
|
+
return {
|
|
284
|
+
tps,
|
|
285
|
+
ttftMs: turn.firstTokenMs - turn.startMs,
|
|
286
|
+
totalMs: endMs - turn.startMs,
|
|
287
|
+
inputTokens,
|
|
288
|
+
outputTokens,
|
|
289
|
+
stallMs: turn.stallMs,
|
|
290
|
+
stallCount: turn.stallCount,
|
|
291
|
+
rateUsdPerMTokens: validCost && validTokens
|
|
292
|
+
? round(costUsd / (totalTokens / 1_000_000), 2)
|
|
293
|
+
: null,
|
|
294
|
+
generationMs: turn.generationMs,
|
|
295
|
+
totalTokens,
|
|
296
|
+
costUsd: validCost ? costUsd : 0,
|
|
297
|
+
measurementMs,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private endAgent(): TurnTelemetry | undefined {
|
|
302
|
+
const startMs = this.agentStartMs;
|
|
303
|
+
const turns = this.agentTurns;
|
|
304
|
+
this.agentStartMs = null;
|
|
305
|
+
this.agentTurns = [];
|
|
306
|
+
if (startMs === null || turns.length === 0) return;
|
|
307
|
+
|
|
308
|
+
const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
|
|
309
|
+
const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
|
|
310
|
+
const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
|
|
311
|
+
const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
|
|
312
|
+
const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
|
|
313
|
+
const stallCount = turns.reduce((sum, turn) => sum + turn.stallCount, 0);
|
|
314
|
+
const measuredTurns = turns.filter((turn) => turn.measurementMs !== null);
|
|
315
|
+
const measuredOutputTokens = measuredTurns.reduce((sum, turn) => sum + turn.outputTokens, 0);
|
|
316
|
+
const measurementMs = measuredTurns.length > 0
|
|
317
|
+
? measuredTurns.reduce((sum, turn) => sum + turn.measurementMs!, 0)
|
|
318
|
+
: null;
|
|
319
|
+
const tps =
|
|
320
|
+
measurementMs !== null && measurementMs >= MIN_GENERATION_MS
|
|
321
|
+
? round(measuredOutputTokens / (measurementMs / 1000), 1)
|
|
322
|
+
: null;
|
|
323
|
+
const boundedTps = tps !== null && tps <= MAX_PLAUSIBLE_TPS ? tps : null;
|
|
324
|
+
const validRate = costUsd > 0 && totalTokens > 0;
|
|
325
|
+
return {
|
|
326
|
+
tps: boundedTps,
|
|
327
|
+
ttftMs: turns[0]!.ttftMs,
|
|
328
|
+
totalMs: this.now() - startMs,
|
|
329
|
+
inputTokens,
|
|
330
|
+
outputTokens,
|
|
331
|
+
stallMs,
|
|
332
|
+
stallCount,
|
|
333
|
+
rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
|
|
334
|
+
generationMs: turns.reduce((sum, turn) => sum + turn.generationMs, 0),
|
|
335
|
+
totalTokens,
|
|
336
|
+
costUsd,
|
|
337
|
+
measurementMs,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatTurnDuration(ms: number): string {
|
|
343
|
+
return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function formatTurnTelemetry(
|
|
347
|
+
telemetry: TurnTelemetry,
|
|
348
|
+
theme: Theme,
|
|
349
|
+
config: TelemetryConfig,
|
|
350
|
+
iconMode: IconMode,
|
|
351
|
+
): string {
|
|
352
|
+
const glyphs = resolveGlyphs(iconMode);
|
|
353
|
+
const parts: string[] = [];
|
|
354
|
+
if (config.tps) {
|
|
355
|
+
const value = telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
|
|
356
|
+
parts.push(theme.fg(telemetry.tps === null ? "muted" : "accent", `${glyphs.speed} TPS ${value}`));
|
|
357
|
+
}
|
|
358
|
+
if (config.ttft) {
|
|
359
|
+
parts.push(theme.fg("text", `${glyphs.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`));
|
|
360
|
+
}
|
|
361
|
+
if (config.duration) {
|
|
362
|
+
parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
|
|
363
|
+
}
|
|
364
|
+
if (config.tokens) {
|
|
365
|
+
parts.push(theme.fg("accent", `${glyphs.input} in ${fmtTokens(telemetry.inputTokens)}`));
|
|
366
|
+
parts.push(theme.fg("success", `${glyphs.output} out ${fmtTokens(telemetry.outputTokens)}`));
|
|
367
|
+
}
|
|
368
|
+
if (config.stalls && telemetry.stallMs > 0) {
|
|
369
|
+
parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
|
|
370
|
+
}
|
|
371
|
+
if (config.cost && telemetry.rateUsdPerMTokens !== null) {
|
|
372
|
+
parts.push(theme.fg("warning", `${glyphs.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`));
|
|
373
|
+
}
|
|
374
|
+
return parts.join(` ${theme.fg("dim", "|")} `);
|
|
375
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-tui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
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
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/OldSuns/pi-open-tui.git"
|
|
9
|
+
},
|
|
6
10
|
"keywords": [
|
|
7
11
|
"pi-package",
|
|
8
12
|
"pi",
|
|
@@ -20,7 +24,7 @@
|
|
|
20
24
|
"LICENSE"
|
|
21
25
|
],
|
|
22
26
|
"scripts": {
|
|
23
|
-
"test": "node --test --experimental-transform-types tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts",
|
|
27
|
+
"test": "node --test --experimental-transform-types tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts",
|
|
24
28
|
"typecheck": "tsc --noEmit"
|
|
25
29
|
},
|
|
26
30
|
"pi": {
|