@wierdbytes/pi-facelift 0.6.2 → 0.6.3
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/CHANGELOG.md +4 -0
- package/README.md +9 -4
- package/index.ts +20 -6
- package/package.json +1 -1
- package/working-time.test.ts +74 -11
- package/working-time.ts +61 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3
|
|
4
|
+
|
|
5
|
+
- **New:** `tps` (output tokens/sec) next to the working timer — live `~`-prefixed estimate from streamed deltas and exact final from `usage.output`.
|
|
6
|
+
|
|
3
7
|
## 0.6.2
|
|
4
8
|
|
|
5
9
|
- **Fix:** live `Working...` timer now uses the same duration format as the history line (`1m5s` instead of `65s`).
|
package/README.md
CHANGED
|
@@ -41,16 +41,21 @@ tool output.
|
|
|
41
41
|
|
|
42
42
|
[cd]: ../common/README.md#diff
|
|
43
43
|
- **working timer** — while the model streams, the `Working...` spinner
|
|
44
|
-
line gains a live ticking timer
|
|
45
|
-
|
|
46
|
-
entry, not sent to the
|
|
44
|
+
line gains a live ticking timer and a token-rate estimate
|
|
45
|
+
(`Working... 12s · tps: ~756`). When the run settles, a muted line is
|
|
46
|
+
left behind in chat history (a durable custom entry, not sent to the
|
|
47
|
+
LLM):
|
|
47
48
|
|
|
48
|
-
`⏱ worked 45.2s (total: 1m15s)`
|
|
49
|
+
`⏱ worked 45.2s (total: 1m15s) · tps: 756`
|
|
49
50
|
|
|
50
51
|
- `worked` — model-only time (request dispatch → response end, so
|
|
51
52
|
time-to-first-token counts; tool execution is excluded).
|
|
52
53
|
- `total` — wall-clock time from your prompt to the final response,
|
|
53
54
|
including retries, tool calls, and all other overhead.
|
|
55
|
+
- `tps` — output tokens per second over the `worked` time. The live
|
|
56
|
+
value is an estimate from streamed deltas (~4 chars ≈ 1 token) and is
|
|
57
|
+
prefixed with `~`; the final value is exact, from the provider's
|
|
58
|
+
reported `usage.output`.
|
|
54
59
|
|
|
55
60
|
Durations auto-format as `1h12m36s` — leading zero parts are dropped
|
|
56
61
|
(`0h12m0s` → `12m0s`); sub-minute values keep one decimal (`45.2s`).
|
package/index.ts
CHANGED
|
@@ -1155,9 +1155,12 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1155
1155
|
// dispatch → assistant message_end, so tools between turns are
|
|
1156
1156
|
// excluded, time-to-first-token included) and `total` (wall-clock
|
|
1157
1157
|
// from user prompt to settle — retries, tools, overhead included).
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1158
|
+
// A `tps` figure accompanies both: live it is an estimate from streamed
|
|
1159
|
+
// deltas (~4 chars/token, prefixed with `~`); final it is exact, from
|
|
1160
|
+
// the provider's usage.output over the worked time. While streaming, a
|
|
1161
|
+
// 1s ticker rewrites the working *message* (spinner frames are left
|
|
1162
|
+
// alone). On `agent_settled` everything is persisted as a durable
|
|
1163
|
+
// custom entry rendered as a plain muted line in history.
|
|
1161
1164
|
// ===================================================================
|
|
1162
1165
|
|
|
1163
1166
|
pi.registerEntryRenderer<WorkingTimeEntry>(WORKING_TIME_ENTRY, (entry, _opts, theme) => {
|
|
@@ -1177,7 +1180,9 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1177
1180
|
};
|
|
1178
1181
|
|
|
1179
1182
|
const renderWorkingMessage = (ctx: SessionContextLike): void => {
|
|
1180
|
-
if (ctx.hasUI)
|
|
1183
|
+
if (ctx.hasUI) {
|
|
1184
|
+
ctx.ui.setWorkingMessage(workingMessageText(workingTracker.elapsedMs(), workingTracker.liveTps()));
|
|
1185
|
+
}
|
|
1181
1186
|
};
|
|
1182
1187
|
|
|
1183
1188
|
// `before_agent_start` fires right after the user submits the prompt,
|
|
@@ -1203,10 +1208,19 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1203
1208
|
workingTick = setInterval(() => renderWorkingMessage(ctx), 1000);
|
|
1204
1209
|
});
|
|
1205
1210
|
|
|
1211
|
+
pi.on("message_update", (event, _ctx) => {
|
|
1212
|
+
if (!faceliftConfig.showWorkingTime) return;
|
|
1213
|
+
const e = event as { message?: { role?: string }; assistantMessageEvent?: { delta?: unknown } };
|
|
1214
|
+
if (e.message?.role !== "assistant") return;
|
|
1215
|
+
const delta = e.assistantMessageEvent?.delta;
|
|
1216
|
+
if (typeof delta === "string" && delta.length > 0) workingTracker.addDelta(delta.length);
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1206
1219
|
pi.on("message_end", (event, _ctx) => {
|
|
1207
1220
|
if (!faceliftConfig.showWorkingTime) return;
|
|
1208
|
-
const
|
|
1209
|
-
if (role !== "assistant") return;
|
|
1221
|
+
const msg = (event as { message?: { role?: string; usage?: { output?: number } } }).message;
|
|
1222
|
+
if (msg?.role !== "assistant") return;
|
|
1223
|
+
if (typeof msg.usage?.output === "number") workingTracker.recordExactTokens(msg.usage.output);
|
|
1210
1224
|
workingTracker.endSegment();
|
|
1211
1225
|
stopWorkingTick();
|
|
1212
1226
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wierdbytes/pi-facelift",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Cosmetic facelift for built-in pi tool output — open-right rounded frames, syntax-highlighted reads, status-aware bash summaries, find/grep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
package/working-time.test.ts
CHANGED
|
@@ -67,6 +67,38 @@ describe("WorkingTimeTracker", () => {
|
|
|
67
67
|
expect(t.settle()?.totalMs).toBe(3000);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
it("estimates live tps from streamed deltas over worked time", () => {
|
|
71
|
+
let now = 0;
|
|
72
|
+
const t = new WorkingTimeTracker(() => now);
|
|
73
|
+
t.beginRun();
|
|
74
|
+
t.beginSegment();
|
|
75
|
+
t.addDelta(4000); // ~1000 estimated tokens at 4 chars/token
|
|
76
|
+
now = 2000; // 2s of worked time
|
|
77
|
+
expect(t.estimatedTokens()).toBe(1000);
|
|
78
|
+
expect(t.liveTps()).toBe(500); // 1000 tokens / 2s
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("liveTps is 0 before any time elapses", () => {
|
|
82
|
+
const t = new WorkingTimeTracker(() => 0);
|
|
83
|
+
t.beginRun();
|
|
84
|
+
t.beginSegment();
|
|
85
|
+
t.addDelta(400);
|
|
86
|
+
expect(t.liveTps()).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("computes exact final tps from recorded tokens over worked ms", () => {
|
|
90
|
+
let now = 0;
|
|
91
|
+
const t = new WorkingTimeTracker(() => now);
|
|
92
|
+
t.beginRun();
|
|
93
|
+
t.beginSegment();
|
|
94
|
+
now = 2000;
|
|
95
|
+
t.recordExactTokens(1500);
|
|
96
|
+
t.endSegment();
|
|
97
|
+
const entry = t.settle();
|
|
98
|
+
expect(entry?.tokens).toBe(1500);
|
|
99
|
+
expect(entry?.tps).toBe(750); // 1500 tokens / 2s worked
|
|
100
|
+
});
|
|
101
|
+
|
|
70
102
|
it("excludes the gap between segments (tool execution time)", () => {
|
|
71
103
|
let now = 0;
|
|
72
104
|
const t = new WorkingTimeTracker(() => now);
|
|
@@ -143,16 +175,23 @@ describe("formatClock", () => {
|
|
|
143
175
|
|
|
144
176
|
describe("workingMessageText / workingTimeLine", () => {
|
|
145
177
|
it("formats the live message using formatClock rules", () => {
|
|
146
|
-
expect(workingMessageText(0)).toBe("Working... 0.0s");
|
|
147
|
-
expect(workingMessageText(1999)).toBe("Working... 2.0s");
|
|
148
|
-
expect(workingMessageText(65_000)).toBe("Working... 1m5s");
|
|
149
|
-
expect(workingMessageText(720_000)).toBe("Working... 12m0s");
|
|
178
|
+
expect(workingMessageText(0, 0)).toBe("Working... 0.0s · tps: ~0");
|
|
179
|
+
expect(workingMessageText(1999, 120)).toBe("Working... 2.0s · tps: ~120");
|
|
180
|
+
expect(workingMessageText(65_000, 756)).toBe("Working... 1m5s · tps: ~756");
|
|
181
|
+
expect(workingMessageText(720_000, 800)).toBe("Working... 12m0s · tps: ~800");
|
|
150
182
|
});
|
|
151
183
|
|
|
152
|
-
it("renders a muted history line with worked
|
|
184
|
+
it("renders a muted history line with worked, total and exact tps", () => {
|
|
153
185
|
const dim = (s: string) => `<dim>${s}</dim>`;
|
|
154
|
-
const entry: WorkingTimeEntry = {
|
|
155
|
-
|
|
186
|
+
const entry: WorkingTimeEntry = {
|
|
187
|
+
ms: 45_200,
|
|
188
|
+
totalMs: 75_000,
|
|
189
|
+
tokens: 34_200,
|
|
190
|
+
tps: 756,
|
|
191
|
+
startedAt: 0,
|
|
192
|
+
endedAt: 0,
|
|
193
|
+
};
|
|
194
|
+
expect(workingTimeLine(entry, dim)).toBe("<dim>⏱ worked 45.2s (total: 1m15s) · tps: 756</dim>");
|
|
156
195
|
});
|
|
157
196
|
});
|
|
158
197
|
|
|
@@ -243,6 +282,30 @@ describe("working-time wiring", () => {
|
|
|
243
282
|
expect(data.totalMs).toBe(10500); // 500 + 5000 + 3000 + 2000
|
|
244
283
|
});
|
|
245
284
|
|
|
285
|
+
it("tracks live estimated tps (~) and final exact tps from usage.output", () => {
|
|
286
|
+
vi.useFakeTimers();
|
|
287
|
+
const { fire, entries, ctx } = loadExtension();
|
|
288
|
+
fire("before_agent_start", {});
|
|
289
|
+
fire("agent_start", {});
|
|
290
|
+
fire("before_provider_request", {});
|
|
291
|
+
// Stream ~4000 chars => ~1000 estimated tokens over 2s of worked time.
|
|
292
|
+
fire("message_update", {
|
|
293
|
+
message: { role: "assistant" },
|
|
294
|
+
assistantMessageEvent: { type: "text_delta", delta: "x".repeat(4000) },
|
|
295
|
+
});
|
|
296
|
+
vi.advanceTimersByTime(2000);
|
|
297
|
+
const calls = ctx.ui.setWorkingMessage.mock.calls.map((c) => c[0]);
|
|
298
|
+
expect(calls).toContain("Working... 2.0s · tps: ~500");
|
|
299
|
+
// Final exact tokens come from usage.output, not the estimate.
|
|
300
|
+
fire("message_end", { message: { role: "assistant", usage: { output: 1500 } } });
|
|
301
|
+
fire("agent_settled", {});
|
|
302
|
+
vi.useRealTimers();
|
|
303
|
+
expect(entries).toHaveLength(1);
|
|
304
|
+
const data = entries[0].data as WorkingTimeEntry;
|
|
305
|
+
expect(data.tokens).toBe(1500);
|
|
306
|
+
expect(data.tps).toBe(750); // 1500 / 2s worked
|
|
307
|
+
});
|
|
308
|
+
|
|
246
309
|
it("ignores non-assistant message_end (does not close the segment early)", () => {
|
|
247
310
|
vi.useFakeTimers();
|
|
248
311
|
const { fire, entries } = loadExtension();
|
|
@@ -267,8 +330,8 @@ describe("working-time wiring", () => {
|
|
|
267
330
|
fire("before_provider_request", {});
|
|
268
331
|
vi.advanceTimersByTime(2000);
|
|
269
332
|
const calls = ctx.ui.setWorkingMessage.mock.calls.map((c) => c[0]);
|
|
270
|
-
expect(calls[0]).toBe("Working... 0.0s");
|
|
271
|
-
expect(calls).toContain("Working... 2.0s");
|
|
333
|
+
expect(calls[0]).toBe("Working... 0.0s · tps: ~0");
|
|
334
|
+
expect(calls).toContain("Working... 2.0s · tps: ~0");
|
|
272
335
|
fire("message_end", asst);
|
|
273
336
|
fire("agent_settled", {});
|
|
274
337
|
vi.useRealTimers();
|
|
@@ -283,10 +346,10 @@ describe("working-time wiring", () => {
|
|
|
283
346
|
expect(renderer).toBeDefined();
|
|
284
347
|
const theme = { fg: (_k: string, s: string) => `dim(${s})` };
|
|
285
348
|
const out = renderer!(
|
|
286
|
-
{ data: { ms: 1500, totalMs: 4000, startedAt: 0, endedAt: 0 } },
|
|
349
|
+
{ data: { ms: 1500, totalMs: 4000, tokens: 300, tps: 200, startedAt: 0, endedAt: 0 } },
|
|
287
350
|
{ expanded: false },
|
|
288
351
|
theme,
|
|
289
352
|
) as MockText;
|
|
290
|
-
expect(out.getText()).toBe("dim(⏱ worked 1.5s (total: 4.0s))");
|
|
353
|
+
expect(out.getText()).toBe("dim(⏱ worked 1.5s (total: 4.0s) · tps: 200)");
|
|
291
354
|
});
|
|
292
355
|
});
|
package/working-time.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* (`before_agent_start`) until the run settles (`agent_settled`).
|
|
15
15
|
* Retries, tool calls, and every other overhead are included.
|
|
16
16
|
*
|
|
17
|
+
* A tokens-per-second (`tps`) figure accompanies both displays:
|
|
18
|
+
* • Live: estimated from streamed deltas (~4 chars ≈ 1 token), marked
|
|
19
|
+
* with a leading `~` to show it is an estimate.
|
|
20
|
+
* • Final: exact, from the provider's `usage.output` divided by the
|
|
21
|
+
* model-only `worked` time.
|
|
22
|
+
*
|
|
17
23
|
* Two consumers:
|
|
18
24
|
* • Live: while the current assistant message streams, a 1s interval
|
|
19
25
|
* calls `setWorkingMessage` with a ticking timer (spinner frames are
|
|
@@ -29,12 +35,19 @@
|
|
|
29
35
|
/** Custom entry type used with `pi.appendEntry` / `registerEntryRenderer`. */
|
|
30
36
|
export const WORKING_TIME_ENTRY = "facelift-working-time";
|
|
31
37
|
|
|
38
|
+
/** Rough chars-per-token ratio used to estimate live tokens from streamed deltas. */
|
|
39
|
+
export const CHARS_PER_TOKEN = 4;
|
|
40
|
+
|
|
32
41
|
/** Payload stored on the durable working-time entry. */
|
|
33
42
|
export interface WorkingTimeEntry {
|
|
34
43
|
/** Model-only streaming time for the run, in milliseconds (tools excluded). */
|
|
35
44
|
ms: number;
|
|
36
45
|
/** Wall-clock time from user prompt to final response, in milliseconds. */
|
|
37
46
|
totalMs: number;
|
|
47
|
+
/** Exact output tokens reported by the provider for this run. */
|
|
48
|
+
tokens: number;
|
|
49
|
+
/** Exact tokens-per-second over the model-only `worked` time. */
|
|
50
|
+
tps: number;
|
|
38
51
|
/** Epoch ms when the run started (user prompt submitted). */
|
|
39
52
|
startedAt: number;
|
|
40
53
|
/** Epoch ms when the run settled. */
|
|
@@ -49,8 +62,9 @@ export interface WorkingTimeEntry {
|
|
|
49
62
|
* before_agent_start → beginRun() (total clock starts here)
|
|
50
63
|
* agent_start → ensureRun() (fallback if before_agent_start skipped)
|
|
51
64
|
* before_provider_request → beginSegment() + start the live ticker
|
|
52
|
-
*
|
|
53
|
-
*
|
|
65
|
+
* message_update (asst) → addDelta() (feed the live tps estimate)
|
|
66
|
+
* message_end (asst) → recordExactTokens() + endSegment() + stop ticker
|
|
67
|
+
* agent_settled → settle() ⇒ { ms, totalMs, tokens, tps }, reset
|
|
54
68
|
*
|
|
55
69
|
* Retries / auto-compaction fire their own agent_start but pi only emits
|
|
56
70
|
* `agent_settled` when it truly stops, so a run is measured end-to-end.
|
|
@@ -60,16 +74,20 @@ export class WorkingTimeTracker {
|
|
|
60
74
|
private accumulatorMs = 0;
|
|
61
75
|
private segmentStart: number | undefined;
|
|
62
76
|
private runStart: number | undefined;
|
|
77
|
+
private runEstChars = 0;
|
|
78
|
+
private runExactTokens = 0;
|
|
63
79
|
|
|
64
80
|
constructor(now: () => number = () => Date.now()) {
|
|
65
81
|
this.now = now;
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
/** Start a fresh run, discarding any prior accumulated time. */
|
|
84
|
+
/** Start a fresh run, discarding any prior accumulated time/tokens. */
|
|
69
85
|
beginRun(): void {
|
|
70
86
|
this.accumulatorMs = 0;
|
|
71
87
|
this.segmentStart = undefined;
|
|
72
88
|
this.runStart = this.now();
|
|
89
|
+
this.runEstChars = 0;
|
|
90
|
+
this.runExactTokens = 0;
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
/** Start a run only if one isn't already in progress (idempotent). */
|
|
@@ -102,6 +120,31 @@ export class WorkingTimeTracker {
|
|
|
102
120
|
return this.runStart !== undefined;
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
/** Accumulate streamed delta characters (input for the live tps estimate). */
|
|
124
|
+
addDelta(chars: number): void {
|
|
125
|
+
if (chars > 0) this.runEstChars += chars;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Record exact output tokens reported for a finished assistant message. */
|
|
129
|
+
recordExactTokens(tokens: number): void {
|
|
130
|
+
if (tokens > 0) this.runExactTokens += tokens;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Estimated tokens received so far this run (~4 chars per token). */
|
|
134
|
+
estimatedTokens(): number {
|
|
135
|
+
return this.runEstChars / CHARS_PER_TOKEN;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Live tokens-per-second: estimated tokens over worked time so far.
|
|
140
|
+
* Returns 0 until some measurable time has elapsed.
|
|
141
|
+
*/
|
|
142
|
+
liveTps(): number {
|
|
143
|
+
const elapsed = this.elapsedMs();
|
|
144
|
+
if (elapsed <= 0) return 0;
|
|
145
|
+
return Math.round(this.estimatedTokens() / (elapsed / 1000));
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
/**
|
|
106
149
|
* Finalize the run and return the durable entry, or `undefined` when
|
|
107
150
|
* no measurable time accrued (guards against junk 0ms entries).
|
|
@@ -112,24 +155,28 @@ export class WorkingTimeTracker {
|
|
|
112
155
|
const endedAt = this.now();
|
|
113
156
|
const ms = this.accumulatorMs;
|
|
114
157
|
const startedAt = this.runStart;
|
|
158
|
+
const tokens = this.runExactTokens;
|
|
115
159
|
this.accumulatorMs = 0;
|
|
116
160
|
this.segmentStart = undefined;
|
|
117
161
|
this.runStart = undefined;
|
|
162
|
+
this.runEstChars = 0;
|
|
163
|
+
this.runExactTokens = 0;
|
|
118
164
|
if (startedAt === undefined || ms <= 0) return undefined;
|
|
119
|
-
|
|
165
|
+
const tps = tokens > 0 ? Math.round(tokens / (ms / 1000)) : 0;
|
|
166
|
+
return { ms, totalMs: Math.max(0, endedAt - startedAt), tokens, tps, startedAt, endedAt };
|
|
120
167
|
}
|
|
121
168
|
}
|
|
122
169
|
|
|
123
170
|
/**
|
|
124
|
-
* Build the live working message, e.g. `Working... 1m5s`. Uses
|
|
125
|
-
* `formatClock` rules as the durable history line so the live and
|
|
126
|
-
* displays stay consistent.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* have configured.
|
|
171
|
+
* Build the live working message, e.g. `Working... 1m5s · tps: ~756`. Uses
|
|
172
|
+
* the same `formatClock` rules as the durable history line so the live and
|
|
173
|
+
* final displays stay consistent. `liveTps` is an estimate (hence the `~`).
|
|
174
|
+
* The animated spinner frames are drawn by pi separately (via
|
|
175
|
+
* `setWorkingIndicator`), so this only supplies the trailing text —
|
|
176
|
+
* preserving whatever spinner the user/other extensions have configured.
|
|
130
177
|
*/
|
|
131
|
-
export function workingMessageText(elapsedMs: number): string {
|
|
132
|
-
return `Working... ${formatClock(elapsedMs)}`;
|
|
178
|
+
export function workingMessageText(elapsedMs: number, liveTps: number): string {
|
|
179
|
+
return `Working... ${formatClock(elapsedMs)} · tps: ~${liveTps}`;
|
|
133
180
|
}
|
|
134
181
|
|
|
135
182
|
/**
|
|
@@ -156,8 +203,8 @@ export function formatClock(ms: number): string {
|
|
|
156
203
|
|
|
157
204
|
/**
|
|
158
205
|
* Render the durable entry body as a plain muted line (no frame):
|
|
159
|
-
* `⏱ worked 45.2s (total: 1m15s)`.
|
|
206
|
+
* `⏱ worked 45.2s (total: 1m15s) · tps: 756`. The tps here is exact (no `~`).
|
|
160
207
|
*/
|
|
161
208
|
export function workingTimeLine(entry: WorkingTimeEntry, dim: (s: string) => string): string {
|
|
162
|
-
return dim(`⏱ worked ${formatClock(entry.ms)} (total: ${formatClock(entry.totalMs)})`);
|
|
209
|
+
return dim(`⏱ worked ${formatClock(entry.ms)} (total: ${formatClock(entry.totalMs)}) · tps: ${entry.tps}`);
|
|
163
210
|
}
|