@wierdbytes/pi-facelift 0.6.2 → 0.6.4

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4
4
+
5
+ - **Fix:** declare `shiki` as a runtime dependency instead of a dev-only one.
6
+ `index.ts` imports `bundledThemes` from `shiki` at runtime, so installs
7
+ without dev dependencies crashed with `Cannot find module 'shiki'` and
8
+ the extension failed to load.
9
+
10
+ ## 0.6.3
11
+
12
+ - **New:** `tps` (output tokens/sec) next to the working timer — live `~`-prefixed estimate from streamed deltas and exact final from `usage.output`.
13
+
3
14
  ## 0.6.2
4
15
 
5
16
  - **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 (`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.2",
3
+ "version": "0.6.4",
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": [
@@ -53,7 +53,8 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@shikijs/cli": "^4.0.2",
56
- "@wierdbytes/pi-common": "^0.4.0"
56
+ "@wierdbytes/pi-common": "^0.4.0",
57
+ "shiki": "^4.0.2"
57
58
  },
58
59
  "peerDependencies": {
59
60
  "@earendil-works/pi-ai": ">=0.74.0",
@@ -64,7 +65,6 @@
64
65
  "@earendil-works/pi-ai": "^0.74.0",
65
66
  "@earendil-works/pi-coding-agent": "^0.74.0",
66
67
  "@earendil-works/pi-tui": "^0.74.0",
67
- "shiki": "^4.0.2",
68
68
  "vitest": "^4.0.18"
69
69
  }
70
70
  }
@@ -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 + total", () => {
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 = { ms: 45_200, totalMs: 75_000, startedAt: 0, endedAt: 0 };
155
- 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>");
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
- * 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,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
- 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... 1m5s`. Uses the same
125
- * `formatClock` rules as the durable history line so the live and final
126
- * displays stay consistent. The animated spinner frames are drawn by pi
127
- * separately (via `setWorkingIndicator`), so this only supplies the
128
- * trailing text — preserving whatever spinner the user/other extensions
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
  }