@wierdbytes/pi-facelift 0.6.1 → 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 +8 -0
- package/README.md +9 -4
- package/index.ts +20 -6
- package/package.json +1 -1
- package/working-time.test.ts +75 -11
- package/working-time.ts +61 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
7
|
+
## 0.6.2
|
|
8
|
+
|
|
9
|
+
- **Fix:** live `Working...` timer now uses the same duration format as the history line (`1m5s` instead of `65s`).
|
|
10
|
+
|
|
3
11
|
## 0.6.1
|
|
4
12
|
|
|
5
13
|
- **New:** live `Working... Ns` timer on the streaming spinner and a muted `⏱ worked … (total: …)` line persisted to chat history per run — `worked` is model time only (tools excluded), `total` is wall-clock incl. retries/tool calls/overhead; toggle via `showWorkingTime` in `/facelift`.
|
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);
|
|
@@ -142,16 +174,24 @@ describe("formatClock", () => {
|
|
|
142
174
|
});
|
|
143
175
|
|
|
144
176
|
describe("workingMessageText / workingTimeLine", () => {
|
|
145
|
-
it("formats the live message
|
|
146
|
-
expect(workingMessageText(0)).toBe("Working... 0s");
|
|
147
|
-
expect(workingMessageText(1999)).toBe("Working...
|
|
148
|
-
expect(workingMessageText(65_000)).toBe("Working...
|
|
177
|
+
it("formats the live message using formatClock rules", () => {
|
|
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");
|
|
149
182
|
});
|
|
150
183
|
|
|
151
|
-
it("renders a muted history line with worked
|
|
184
|
+
it("renders a muted history line with worked, total and exact tps", () => {
|
|
152
185
|
const dim = (s: string) => `<dim>${s}</dim>`;
|
|
153
|
-
const entry: WorkingTimeEntry = {
|
|
154
|
-
|
|
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>");
|
|
155
195
|
});
|
|
156
196
|
});
|
|
157
197
|
|
|
@@ -242,6 +282,30 @@ describe("working-time wiring", () => {
|
|
|
242
282
|
expect(data.totalMs).toBe(10500); // 500 + 5000 + 3000 + 2000
|
|
243
283
|
});
|
|
244
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
|
+
|
|
245
309
|
it("ignores non-assistant message_end (does not close the segment early)", () => {
|
|
246
310
|
vi.useFakeTimers();
|
|
247
311
|
const { fire, entries } = loadExtension();
|
|
@@ -266,8 +330,8 @@ describe("working-time wiring", () => {
|
|
|
266
330
|
fire("before_provider_request", {});
|
|
267
331
|
vi.advanceTimersByTime(2000);
|
|
268
332
|
const calls = ctx.ui.setWorkingMessage.mock.calls.map((c) => c[0]);
|
|
269
|
-
expect(calls[0]).toBe("Working... 0s");
|
|
270
|
-
expect(calls).toContain("Working...
|
|
333
|
+
expect(calls[0]).toBe("Working... 0.0s · tps: ~0");
|
|
334
|
+
expect(calls).toContain("Working... 2.0s · tps: ~0");
|
|
271
335
|
fire("message_end", asst);
|
|
272
336
|
fire("agent_settled", {});
|
|
273
337
|
vi.useRealTimers();
|
|
@@ -282,10 +346,10 @@ describe("working-time wiring", () => {
|
|
|
282
346
|
expect(renderer).toBeDefined();
|
|
283
347
|
const theme = { fg: (_k: string, s: string) => `dim(${s})` };
|
|
284
348
|
const out = renderer!(
|
|
285
|
-
{ data: { ms: 1500, totalMs: 4000, startedAt: 0, endedAt: 0 } },
|
|
349
|
+
{ data: { ms: 1500, totalMs: 4000, tokens: 300, tps: 200, startedAt: 0, endedAt: 0 } },
|
|
286
350
|
{ expanded: false },
|
|
287
351
|
theme,
|
|
288
352
|
) as MockText;
|
|
289
|
-
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)");
|
|
290
354
|
});
|
|
291
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,23 +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...
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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.
|
|
128
177
|
*/
|
|
129
|
-
export function workingMessageText(elapsedMs: number): string {
|
|
130
|
-
|
|
131
|
-
return `Working... ${sec}s`;
|
|
178
|
+
export function workingMessageText(elapsedMs: number, liveTps: number): string {
|
|
179
|
+
return `Working... ${formatClock(elapsedMs)} · tps: ~${liveTps}`;
|
|
132
180
|
}
|
|
133
181
|
|
|
134
182
|
/**
|
|
@@ -155,8 +203,8 @@ export function formatClock(ms: number): string {
|
|
|
155
203
|
|
|
156
204
|
/**
|
|
157
205
|
* Render the durable entry body as a plain muted line (no frame):
|
|
158
|
-
* `⏱ worked 45.2s (total: 1m15s)`.
|
|
206
|
+
* `⏱ worked 45.2s (total: 1m15s) · tps: 756`. The tps here is exact (no `~`).
|
|
159
207
|
*/
|
|
160
208
|
export function workingTimeLine(entry: WorkingTimeEntry, dim: (s: string) => string): string {
|
|
161
|
-
return dim(`⏱ worked ${formatClock(entry.ms)} (total: ${formatClock(entry.totalMs)})`);
|
|
209
|
+
return dim(`⏱ worked ${formatClock(entry.ms)} (total: ${formatClock(entry.totalMs)}) · tps: ${entry.tps}`);
|
|
162
210
|
}
|