pi-editor-footer 0.1.0
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/AGENTS.md +19 -0
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +29 -0
- package/README.md +84 -0
- package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
- package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/reference/pi-tui-internals.md +144 -0
- package/docs/specs/01-config.md +57 -0
- package/docs/specs/02-identity.md +20 -0
- package/docs/specs/03-border-telemetry.md +48 -0
- package/docs/specs/04-header.md +30 -0
- package/docs/specs/05-footer.md +30 -0
- package/docs/specs/06-git.md +36 -0
- package/docs/specs/07-runtime.md +28 -0
- package/docs/specs/theme-overview.md +116 -0
- package/package.json +16 -0
- package/src/config.ts +184 -0
- package/src/detail-render.ts +119 -0
- package/src/footer.ts +479 -0
- package/src/git.ts +170 -0
- package/src/header.ts +185 -0
- package/src/icons.ts +197 -0
- package/src/index.ts +607 -0
- package/src/model-info.ts +341 -0
- package/src/runtime.ts +318 -0
- package/src/state.ts +144 -0
- package/src/telemetry.ts +437 -0
- package/src/theme-settings.ts +461 -0
- package/src/tracking-editor.ts +352 -0
- package/src/utils-workspace.ts +48 -0
- package/src/utils.ts +388 -0
- package/src/window-presentation.ts +56 -0
- package/test/config.test.ts +146 -0
- package/test/detail-render.test.ts +202 -0
- package/test/footer.test.ts +86 -0
- package/test/git.test.ts +45 -0
- package/test/header.test.ts +169 -0
- package/test/icons.test.ts +24 -0
- package/test/runtime.test.ts +71 -0
- package/test/telemetry.test.ts +199 -0
- package/test/utils.test.ts +71 -0
- package/test/window-presentation.test.ts +73 -0
- package/tsconfig.json +13 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { GitStatus } from "./git.js";
|
|
2
|
+
import { emptyGitStatus } from "./git.js";
|
|
3
|
+
import type { RuntimeInfo } from "./runtime.js";
|
|
4
|
+
import { fmtTokens, formatProviderLabel } from "./utils.js";
|
|
5
|
+
|
|
6
|
+
export interface FooterState {
|
|
7
|
+
git: GitStatus;
|
|
8
|
+
runtime: RuntimeInfo | null;
|
|
9
|
+
sessionStartEpoch: number;
|
|
10
|
+
workingSince: number | undefined;
|
|
11
|
+
lastDoneIn: number | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UsageTotals {
|
|
15
|
+
input: number;
|
|
16
|
+
output: number;
|
|
17
|
+
cacheRead: number;
|
|
18
|
+
cacheWrite: number;
|
|
19
|
+
cost: number;
|
|
20
|
+
latestCacheHitRate: number | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let usageCache: { key: string; totals: UsageTotals } | undefined;
|
|
24
|
+
|
|
25
|
+
function entriesKey(ctx: {
|
|
26
|
+
sessionManager?: { getEntries(): unknown[] };
|
|
27
|
+
}): string {
|
|
28
|
+
const entries = ctx.sessionManager?.getEntries() ?? [];
|
|
29
|
+
const last = entries.at(-1) as
|
|
30
|
+
| { id?: string; timestamp?: string }
|
|
31
|
+
| undefined;
|
|
32
|
+
return `${entries.length}:${String(last?.id ?? "")}:${String(last?.timestamp ?? "")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getUsageTotals(ctx: {
|
|
36
|
+
sessionManager?: {
|
|
37
|
+
getEntries(): {
|
|
38
|
+
type: string;
|
|
39
|
+
message?: {
|
|
40
|
+
role: string;
|
|
41
|
+
usage?: {
|
|
42
|
+
input?: number;
|
|
43
|
+
output?: number;
|
|
44
|
+
cacheRead?: number;
|
|
45
|
+
cacheWrite?: number;
|
|
46
|
+
cost?: { total?: number };
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}[];
|
|
50
|
+
};
|
|
51
|
+
}): UsageTotals {
|
|
52
|
+
const key = entriesKey(
|
|
53
|
+
ctx as unknown as { sessionManager?: { getEntries(): unknown[] } },
|
|
54
|
+
);
|
|
55
|
+
if (usageCache && usageCache.key === key) return usageCache.totals;
|
|
56
|
+
|
|
57
|
+
const totals: UsageTotals = {
|
|
58
|
+
input: 0,
|
|
59
|
+
output: 0,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
cost: 0,
|
|
63
|
+
latestCacheHitRate: undefined,
|
|
64
|
+
};
|
|
65
|
+
const entries =
|
|
66
|
+
(
|
|
67
|
+
ctx as unknown as {
|
|
68
|
+
sessionManager?: {
|
|
69
|
+
getEntries(): {
|
|
70
|
+
type: string;
|
|
71
|
+
message?: {
|
|
72
|
+
role: string;
|
|
73
|
+
usage?: {
|
|
74
|
+
input?: number;
|
|
75
|
+
output?: number;
|
|
76
|
+
cacheRead?: number;
|
|
77
|
+
cacheWrite?: number;
|
|
78
|
+
cost?: { total?: number };
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}[];
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
).sessionManager?.getEntries() ?? [];
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
87
|
+
const u = entry.message.usage;
|
|
88
|
+
if (!u) continue;
|
|
89
|
+
totals.input += u.input ?? 0;
|
|
90
|
+
totals.output += u.output ?? 0;
|
|
91
|
+
totals.cacheRead += u.cacheRead ?? 0;
|
|
92
|
+
totals.cacheWrite += u.cacheWrite ?? 0;
|
|
93
|
+
totals.cost += u.cost?.total ?? 0;
|
|
94
|
+
const promptTokens =
|
|
95
|
+
(u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
96
|
+
if (promptTokens > 0) {
|
|
97
|
+
totals.latestCacheHitRate = ((u.cacheRead ?? 0) / promptTokens) * 100;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
usageCache = { key, totals };
|
|
102
|
+
return totals;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function invalidateUsageCache(): void {
|
|
106
|
+
usageCache = undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createInitialState(): FooterState {
|
|
110
|
+
return {
|
|
111
|
+
git: emptyGitStatus(),
|
|
112
|
+
runtime: null,
|
|
113
|
+
sessionStartEpoch: Date.now(),
|
|
114
|
+
workingSince: undefined,
|
|
115
|
+
lastDoneIn: undefined,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ModelMeta {
|
|
120
|
+
provider: string;
|
|
121
|
+
model: string;
|
|
122
|
+
effort: string | undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getModelMeta(
|
|
126
|
+
ctx: {
|
|
127
|
+
model?: {
|
|
128
|
+
provider?: string;
|
|
129
|
+
name?: string;
|
|
130
|
+
id?: string;
|
|
131
|
+
reasoning?: boolean;
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
getThinkingLevel: () => string,
|
|
135
|
+
): ModelMeta {
|
|
136
|
+
const provider = formatProviderLabel(ctx.model?.provider);
|
|
137
|
+
const model = ctx.model?.name ?? ctx.model?.id ?? "no-model";
|
|
138
|
+
const reasoning = ctx.model?.reasoning ?? false;
|
|
139
|
+
const effort = reasoning ? getThinkingLevel() : undefined;
|
|
140
|
+
return { provider, model, effort };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Keep fmtTokens usage to satisfy import, used by getUsageTotals display elsewhere
|
|
144
|
+
void fmtTokens;
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn telemetry tracker — pure, TUI-free.
|
|
3
|
+
*
|
|
4
|
+
* Rebuilt bespoke from tmp/pi-open-tui/extensions/open-tui/telemetry.ts.
|
|
5
|
+
* No imports beyond stdlib and pi-tui width utils omitted here (pure maths).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STALL_THRESHOLD_MS = 1000;
|
|
9
|
+
|
|
10
|
+
export interface TurnTelemetry {
|
|
11
|
+
tps: number | null;
|
|
12
|
+
ttftMs: number;
|
|
13
|
+
totalMs: number;
|
|
14
|
+
inputTokens: number;
|
|
15
|
+
outputTokens: number;
|
|
16
|
+
stallMs: number;
|
|
17
|
+
stallCount: number;
|
|
18
|
+
rateUsdPerMTokens: number | null;
|
|
19
|
+
generationMs: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
costUsd: number;
|
|
22
|
+
measurementMs: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TelemetryConfig {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
tps: boolean;
|
|
28
|
+
ttft: boolean;
|
|
29
|
+
duration: boolean;
|
|
30
|
+
tokens: boolean;
|
|
31
|
+
stalls: boolean;
|
|
32
|
+
cost: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type AssistantMessage = {
|
|
36
|
+
role: "assistant";
|
|
37
|
+
content: unknown;
|
|
38
|
+
api?: string;
|
|
39
|
+
provider?: string;
|
|
40
|
+
model?: string;
|
|
41
|
+
usage: {
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
totalTokens: number;
|
|
45
|
+
cost: {
|
|
46
|
+
input: number;
|
|
47
|
+
output: number;
|
|
48
|
+
cacheRead: number;
|
|
49
|
+
cacheWrite: number;
|
|
50
|
+
total: number;
|
|
51
|
+
};
|
|
52
|
+
cacheRead?: number;
|
|
53
|
+
cacheWrite?: number;
|
|
54
|
+
};
|
|
55
|
+
stopReason?: string;
|
|
56
|
+
timestamp?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type AgentMessage = { role: string } & AssistantMessage;
|
|
60
|
+
|
|
61
|
+
export type TelemetryEvent =
|
|
62
|
+
| { type: "agent_start" }
|
|
63
|
+
| { type: "agent_settled" }
|
|
64
|
+
| { type: "turn_start"; turnIndex?: number; timestamp?: number }
|
|
65
|
+
| { type: "message_start"; message: AgentMessage }
|
|
66
|
+
| {
|
|
67
|
+
type: "message_update";
|
|
68
|
+
message: AgentMessage;
|
|
69
|
+
assistantMessageEvent: {
|
|
70
|
+
type: string;
|
|
71
|
+
delta: string;
|
|
72
|
+
contentIndex?: number;
|
|
73
|
+
partial?: unknown;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
| { type: "message_end"; message: AgentMessage }
|
|
77
|
+
| { type: "tool_execution_start"; [key: string]: unknown }
|
|
78
|
+
| {
|
|
79
|
+
type: "turn_end";
|
|
80
|
+
turnIndex?: number;
|
|
81
|
+
message?: AgentMessage;
|
|
82
|
+
toolResults?: unknown[];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
interface MessageTiming {
|
|
86
|
+
lastUpdateMs: number;
|
|
87
|
+
firstOutputMs: number | null;
|
|
88
|
+
inStall: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface TurnTiming {
|
|
92
|
+
startMs: number;
|
|
93
|
+
firstTokenMs: number | null;
|
|
94
|
+
currentMessage: MessageTiming | null;
|
|
95
|
+
messages: AssistantMessage[];
|
|
96
|
+
generationMs: number;
|
|
97
|
+
stallMs: number;
|
|
98
|
+
stallCount: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isAssistantMessage(message: AgentMessage): boolean {
|
|
102
|
+
return message.role === "assistant";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function round(value: number, decimals: number): number {
|
|
106
|
+
const factor = 10 ** decimals;
|
|
107
|
+
return Math.round(value * factor) / factor;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function fmtTokens(n: number): string {
|
|
111
|
+
if (n < 1000) return n.toString();
|
|
112
|
+
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
113
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
114
|
+
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
115
|
+
return `${Math.round(n / 1_000_000)}M`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatDuration(ms: number): string {
|
|
119
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
120
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
121
|
+
const s = totalSeconds % 60;
|
|
122
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
123
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${s}s`;
|
|
124
|
+
const m = totalMinutes % 60;
|
|
125
|
+
const h = Math.floor(totalMinutes / 60);
|
|
126
|
+
return `${h}h ${m}m ${s}s`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class TurnTelemetryTracker {
|
|
130
|
+
private readonly now: () => number;
|
|
131
|
+
private turn: TurnTiming | undefined;
|
|
132
|
+
private agentStartMs: number | null = null;
|
|
133
|
+
private agentTurns: TurnTelemetry[] = [];
|
|
134
|
+
private lastTelemetry: TurnTelemetry | null = null;
|
|
135
|
+
|
|
136
|
+
constructor(now: () => number = () => performance.now()) {
|
|
137
|
+
this.now = now;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getLastTelemetry(): TurnTelemetry | null {
|
|
141
|
+
return this.lastTelemetry;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
handle(event: TelemetryEvent): TurnTelemetry | undefined {
|
|
145
|
+
switch (event.type) {
|
|
146
|
+
case "agent_start":
|
|
147
|
+
if (this.agentStartMs === null) {
|
|
148
|
+
this.agentStartMs = this.now();
|
|
149
|
+
this.agentTurns = [];
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
case "agent_settled":
|
|
153
|
+
return this.endAgent();
|
|
154
|
+
case "turn_start":
|
|
155
|
+
this.startTurn();
|
|
156
|
+
return;
|
|
157
|
+
case "message_start":
|
|
158
|
+
this.startMessage(event.message);
|
|
159
|
+
return;
|
|
160
|
+
case "message_update":
|
|
161
|
+
this.updateMessage(
|
|
162
|
+
event as {
|
|
163
|
+
type: "message_update";
|
|
164
|
+
message: AgentMessage;
|
|
165
|
+
assistantMessageEvent: { type: string; delta: string };
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
case "message_end":
|
|
170
|
+
this.endMessage(event.message);
|
|
171
|
+
return;
|
|
172
|
+
case "tool_execution_start":
|
|
173
|
+
return;
|
|
174
|
+
case "turn_end":
|
|
175
|
+
return this.endTurnAndCollect();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private startTurn(): void {
|
|
180
|
+
this.turn = {
|
|
181
|
+
startMs: this.now(),
|
|
182
|
+
firstTokenMs: null,
|
|
183
|
+
currentMessage: null,
|
|
184
|
+
messages: [],
|
|
185
|
+
generationMs: 0,
|
|
186
|
+
stallMs: 0,
|
|
187
|
+
stallCount: 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private startMessage(message: AgentMessage): void {
|
|
192
|
+
if (!this.turn || !isAssistantMessage(message)) return;
|
|
193
|
+
const now = this.now();
|
|
194
|
+
this.turn.currentMessage = {
|
|
195
|
+
lastUpdateMs: now,
|
|
196
|
+
firstOutputMs: null,
|
|
197
|
+
inStall: false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private updateMessage(event: {
|
|
202
|
+
message: AgentMessage;
|
|
203
|
+
assistantMessageEvent: { type: string; delta: string };
|
|
204
|
+
}): void {
|
|
205
|
+
const turn = this.turn;
|
|
206
|
+
const current = turn?.currentMessage;
|
|
207
|
+
const streamEvent = event.assistantMessageEvent;
|
|
208
|
+
if (
|
|
209
|
+
streamEvent.type !== "text_delta" &&
|
|
210
|
+
streamEvent.type !== "thinking_delta" &&
|
|
211
|
+
streamEvent.type !== "toolcall_delta"
|
|
212
|
+
)
|
|
213
|
+
return;
|
|
214
|
+
if (streamEvent.delta.length === 0) return;
|
|
215
|
+
const message = event.message;
|
|
216
|
+
if (!turn || !current || !isAssistantMessage(message)) return;
|
|
217
|
+
|
|
218
|
+
const now = this.now();
|
|
219
|
+
if (current.firstOutputMs === null) {
|
|
220
|
+
current.firstOutputMs = now;
|
|
221
|
+
turn.firstTokenMs ??= now;
|
|
222
|
+
current.lastUpdateMs = now;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const gap = now - current.lastUpdateMs;
|
|
227
|
+
if (gap >= STALL_THRESHOLD_MS) {
|
|
228
|
+
if (!current.inStall) turn.stallCount++;
|
|
229
|
+
current.inStall = true;
|
|
230
|
+
turn.stallMs += gap;
|
|
231
|
+
} else {
|
|
232
|
+
current.inStall = false;
|
|
233
|
+
}
|
|
234
|
+
current.lastUpdateMs = now;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private endMessage(message: AgentMessage): void {
|
|
238
|
+
const turn = this.turn;
|
|
239
|
+
if (!turn || !isAssistantMessage(message)) return;
|
|
240
|
+
|
|
241
|
+
const current = turn.currentMessage;
|
|
242
|
+
if (current) {
|
|
243
|
+
const endMs = this.now();
|
|
244
|
+
turn.generationMs = endMs - turn.startMs;
|
|
245
|
+
if (current.firstOutputMs === null && message.usage.output > 0) {
|
|
246
|
+
turn.firstTokenMs ??= endMs;
|
|
247
|
+
}
|
|
248
|
+
turn.currentMessage = null;
|
|
249
|
+
}
|
|
250
|
+
turn.messages.push(message as AssistantMessage);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private endTurnAndCollect(): TurnTelemetry | undefined {
|
|
254
|
+
const telemetry = this.endTurn();
|
|
255
|
+
if (telemetry && this.agentStartMs !== null)
|
|
256
|
+
this.agentTurns.push(telemetry);
|
|
257
|
+
if (telemetry) this.lastTelemetry = telemetry;
|
|
258
|
+
return telemetry;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private endTurn(): TurnTelemetry | undefined {
|
|
262
|
+
const turn = this.turn;
|
|
263
|
+
this.turn = undefined;
|
|
264
|
+
if (!turn || turn.firstTokenMs === null || turn.messages.length === 0)
|
|
265
|
+
return;
|
|
266
|
+
|
|
267
|
+
const endMs = this.now();
|
|
268
|
+
let inputTokens = 0;
|
|
269
|
+
let outputTokens = 0;
|
|
270
|
+
let totalTokens = 0;
|
|
271
|
+
let costUsd = 0;
|
|
272
|
+
for (const message of turn.messages) {
|
|
273
|
+
inputTokens += message.usage.input;
|
|
274
|
+
outputTokens += message.usage.output;
|
|
275
|
+
totalTokens += message.usage.totalTokens;
|
|
276
|
+
costUsd += message.usage.cost.total;
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
![inputTokens, outputTokens, totalTokens, costUsd].every(Number.isFinite)
|
|
280
|
+
) {
|
|
281
|
+
throw new Error("Invalid assistant usage in turn telemetry");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const measurementMs =
|
|
285
|
+
outputTokens > 0 && turn.generationMs > 0 ? turn.generationMs : null;
|
|
286
|
+
const tps =
|
|
287
|
+
measurementMs === null
|
|
288
|
+
? null
|
|
289
|
+
: round(outputTokens / (measurementMs / 1000), 1);
|
|
290
|
+
const validCost = Number.isFinite(costUsd) && costUsd > 0;
|
|
291
|
+
const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
|
|
292
|
+
return {
|
|
293
|
+
tps,
|
|
294
|
+
ttftMs: turn.firstTokenMs! - turn.startMs,
|
|
295
|
+
totalMs: endMs - turn.startMs,
|
|
296
|
+
inputTokens,
|
|
297
|
+
outputTokens,
|
|
298
|
+
stallMs: turn.stallMs,
|
|
299
|
+
stallCount: turn.stallCount,
|
|
300
|
+
rateUsdPerMTokens:
|
|
301
|
+
validCost && validTokens
|
|
302
|
+
? round(costUsd / (totalTokens / 1_000_000), 2)
|
|
303
|
+
: null,
|
|
304
|
+
generationMs: turn.generationMs,
|
|
305
|
+
totalTokens,
|
|
306
|
+
costUsd: validCost ? costUsd : 0,
|
|
307
|
+
measurementMs,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private endAgent(): TurnTelemetry | undefined {
|
|
312
|
+
const startMs = this.agentStartMs;
|
|
313
|
+
const turns = this.agentTurns;
|
|
314
|
+
this.agentStartMs = null;
|
|
315
|
+
this.agentTurns = [];
|
|
316
|
+
if (startMs === null || turns.length === 0) return;
|
|
317
|
+
|
|
318
|
+
const outputTokens = turns.reduce((sum, t) => sum + t.outputTokens, 0);
|
|
319
|
+
const inputTokens = turns.reduce((sum, t) => sum + t.inputTokens, 0);
|
|
320
|
+
const totalTokens = turns.reduce((sum, t) => sum + t.totalTokens, 0);
|
|
321
|
+
const costUsd = turns.reduce((sum, t) => sum + t.costUsd, 0);
|
|
322
|
+
const stallMs = turns.reduce((sum, t) => sum + t.stallMs, 0);
|
|
323
|
+
const stallCount = turns.reduce((sum, t) => sum + t.stallCount, 0);
|
|
324
|
+
const generationMs = turns.reduce((sum, t) => sum + t.generationMs, 0);
|
|
325
|
+
const measurementMs =
|
|
326
|
+
outputTokens > 0 && generationMs > 0 ? generationMs : null;
|
|
327
|
+
const tps =
|
|
328
|
+
measurementMs === null
|
|
329
|
+
? null
|
|
330
|
+
: round(outputTokens / (measurementMs / 1000), 1);
|
|
331
|
+
const validRate = costUsd > 0 && totalTokens > 0;
|
|
332
|
+
const result: TurnTelemetry = {
|
|
333
|
+
tps,
|
|
334
|
+
ttftMs: turns[0]!.ttftMs,
|
|
335
|
+
totalMs: this.now() - startMs,
|
|
336
|
+
inputTokens,
|
|
337
|
+
outputTokens,
|
|
338
|
+
stallMs,
|
|
339
|
+
stallCount,
|
|
340
|
+
rateUsdPerMTokens: validRate
|
|
341
|
+
? round(costUsd / (totalTokens / 1_000_000), 2)
|
|
342
|
+
: null,
|
|
343
|
+
generationMs,
|
|
344
|
+
totalTokens,
|
|
345
|
+
costUsd,
|
|
346
|
+
measurementMs,
|
|
347
|
+
};
|
|
348
|
+
this.lastTelemetry = result;
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatTurnDuration(ms: number): string {
|
|
354
|
+
return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface MinimalTheme {
|
|
358
|
+
fg(color: string, text: string): string;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function formatTurnTelemetry(
|
|
362
|
+
telemetry: TurnTelemetry,
|
|
363
|
+
theme: MinimalTheme,
|
|
364
|
+
config: TelemetryConfig,
|
|
365
|
+
glyphs?: {
|
|
366
|
+
speed: string;
|
|
367
|
+
latency: string;
|
|
368
|
+
done: string;
|
|
369
|
+
input: string;
|
|
370
|
+
output: string;
|
|
371
|
+
stall: string;
|
|
372
|
+
cost: string;
|
|
373
|
+
dimSep?: string;
|
|
374
|
+
},
|
|
375
|
+
): string {
|
|
376
|
+
const g = glyphs ?? {
|
|
377
|
+
speed: ">",
|
|
378
|
+
latency: "~",
|
|
379
|
+
done: "+",
|
|
380
|
+
input: "↑",
|
|
381
|
+
output: "↓",
|
|
382
|
+
stall: "!",
|
|
383
|
+
cost: "$",
|
|
384
|
+
};
|
|
385
|
+
const parts: string[] = [];
|
|
386
|
+
if (config.tps) {
|
|
387
|
+
const value =
|
|
388
|
+
telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
|
|
389
|
+
parts.push(
|
|
390
|
+
theme.fg(
|
|
391
|
+
telemetry.tps === null ? "muted" : "accent",
|
|
392
|
+
`${g.speed} TPS ${value}`,
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (config.ttft) {
|
|
397
|
+
parts.push(
|
|
398
|
+
theme.fg(
|
|
399
|
+
"text",
|
|
400
|
+
`${g.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`,
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (config.duration) {
|
|
405
|
+
parts.push(
|
|
406
|
+
theme.fg("success", `${g.done} ${formatTurnDuration(telemetry.totalMs)}`),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (config.tokens) {
|
|
410
|
+
parts.push(
|
|
411
|
+
theme.fg("accent", `${g.input} ${fmtTokens(telemetry.inputTokens)}`),
|
|
412
|
+
);
|
|
413
|
+
parts.push(
|
|
414
|
+
theme.fg("success", `${g.output} ${fmtTokens(telemetry.outputTokens)}`),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (config.stalls && telemetry.stallMs > 0) {
|
|
418
|
+
parts.push(
|
|
419
|
+
theme.fg(
|
|
420
|
+
"warning",
|
|
421
|
+
`${g.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`,
|
|
422
|
+
),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (config.cost && telemetry.rateUsdPerMTokens !== null) {
|
|
426
|
+
parts.push(
|
|
427
|
+
theme.fg(
|
|
428
|
+
"warning",
|
|
429
|
+
`${g.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`,
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (parts.length === 0) return "";
|
|
434
|
+
// Use theme dim for separator if not custom
|
|
435
|
+
const joiner = g.dimSep ?? ` ${theme.fg("dim", "|")} `;
|
|
436
|
+
return parts.join(joiner);
|
|
437
|
+
}
|