@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 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 (`Working... 12s`). When the run
45
- settles, a muted line is left behind in chat history (a durable custom
46
- entry, not sent to the LLM) reporting two numbers:
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
- // While streaming, a 1s ticker rewrites the working *message* (spinner
1159
- // frames are left alone). On `agent_settled` both are persisted as a
1160
- // durable custom entry rendered as a plain muted line in history.
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) ctx.ui.setWorkingMessage(workingMessageText(workingTracker.elapsedMs()));
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 role = (event as { message?: { role?: string } }).message?.role;
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.1",
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": [
@@ -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 in whole seconds", () => {
146
- expect(workingMessageText(0)).toBe("Working... 0s");
147
- expect(workingMessageText(1999)).toBe("Working... 1s");
148
- expect(workingMessageText(65_000)).toBe("Working... 65s");
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 + total", () => {
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 = { ms: 45_200, totalMs: 75_000, startedAt: 0, endedAt: 0 };
154
- expect(workingTimeLine(entry, dim)).toBe("<dim>⏱ worked 45.2s (total: 1m15s)</dim>");
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... 2s");
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
- * message_end (asst) → endSegment() + stop the ticker
53
- * agent_settled → settle() ⇒ { ms, totalMs }, then reset
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
- return { ms, totalMs: Math.max(0, endedAt - startedAt), startedAt, endedAt };
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... 12s`. The animated
125
- * spinner frames are drawn by pi separately (via `setWorkingIndicator`),
126
- * so this only supplies the trailing text — preserving whatever spinner
127
- * the user/other extensions 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.
128
177
  */
129
- export function workingMessageText(elapsedMs: number): string {
130
- const sec = Math.max(0, Math.floor(elapsedMs / 1000));
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
  }