@wierdbytes/pi-facelift 0.5.0 → 0.6.1

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ - **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`.
6
+
3
7
  ## 0.5.0
4
8
 
5
9
  - **Fix:** facelift no longer silently disables itself on pi 0.79.x — tool
package/README.md CHANGED
@@ -40,6 +40,22 @@ tool output.
40
40
  don't happen — configurable via `diffLayout` (see below).
41
41
 
42
42
  [cd]: ../common/README.md#diff
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:
47
+
48
+ `⏱ worked 45.2s (total: 1m15s)`
49
+
50
+ - `worked` — model-only time (request dispatch → response end, so
51
+ time-to-first-token counts; tool execution is excluded).
52
+ - `total` — wall-clock time from your prompt to the final response,
53
+ including retries, tool calls, and all other overhead.
54
+
55
+ Durations auto-format as `1h12m36s` — leading zero parts are dropped
56
+ (`0h12m0s` → `12m0s`); sub-minute values keep one decimal (`45.2s`).
57
+
58
+ Toggle via `showWorkingTime` in `/facelift`.
43
59
 
44
60
  ## Install
45
61
 
@@ -100,7 +116,13 @@ Current schema:
100
116
  // "unified" : always stacked single-column.
101
117
  // "per-edit" : each diff picks independently (original pi-diff
102
118
  // behaviour; can produce mixed layouts).
103
- "diffLayout": "consistent"
119
+ "diffLayout": "consistent",
120
+
121
+ // Show a ticking timer in the streaming "Working..." line and persist
122
+ // each run's model time (`worked`, tools excluded) and wall-clock time
123
+ // (`total`, retries + tools + overhead) as a muted line in chat
124
+ // history. Default true.
125
+ "showWorkingTime": true
104
126
  }
105
127
  ```
106
128
 
@@ -118,6 +140,7 @@ icons, image protocols) or boot-time toggles:
118
140
  | `FACELIFT_ICONS` | `nerd` | Set to `none` / `off` to disable Nerd Font icons in `ls`/`find`/`grep`. |
119
141
  | `FACELIFT_IMAGE_PROTOCOL` | auto | Force `kitty` / `iterm2` / `none`. Auto-detected from `$TERM_PROGRAM` etc. |
120
142
  | `DIFF_LAYOUT` | (none) | One-shot override for `diffLayout` on first-run seeding. |
143
+ | `FACELIFT_SHOW_WORKING_TIME` | `true` | One-shot override for `showWorkingTime` on first-run seeding (`0`/`off` ⇒ off). |
121
144
 
122
145
  ## Development
123
146
 
@@ -126,7 +149,7 @@ This package lives in the [`pi-wierd-stuff`](../../README.md) monorepo.
126
149
  ```bash
127
150
  bun install # from the repo root
128
151
  cd packages/facelift
129
- bun run test # 64 tests
152
+ bun run test # 80 tests
130
153
  bun run test:watch # vitest watch mode
131
154
  bun run demo # render every tool block to your terminal
132
155
  ```
@@ -54,6 +54,8 @@ function loadBashTool() {
54
54
  registerTool: (tool: any) => tools.set(tool.name, tool),
55
55
  registerCommand: () => {},
56
56
  on: () => {},
57
+ registerEntryRenderer: () => {},
58
+ appendEntry: () => {},
57
59
  };
58
60
 
59
61
  piFaceliftExtension(pi, {
package/config.test.ts CHANGED
@@ -38,34 +38,67 @@ beforeEach(() => {
38
38
  configFile = join(dir, "config.json");
39
39
  savedEnv.DIFF_LAYOUT = process.env.DIFF_LAYOUT;
40
40
  delete process.env.DIFF_LAYOUT;
41
+ savedEnv.FACELIFT_SHOW_WORKING_TIME = process.env.FACELIFT_SHOW_WORKING_TIME;
42
+ delete process.env.FACELIFT_SHOW_WORKING_TIME;
41
43
  });
42
44
 
43
45
  afterEach(() => {
44
46
  if (savedEnv.DIFF_LAYOUT !== undefined) process.env.DIFF_LAYOUT = savedEnv.DIFF_LAYOUT;
45
47
  else delete process.env.DIFF_LAYOUT;
48
+ if (savedEnv.FACELIFT_SHOW_WORKING_TIME !== undefined)
49
+ process.env.FACELIFT_SHOW_WORKING_TIME = savedEnv.FACELIFT_SHOW_WORKING_TIME;
50
+ else delete process.env.FACELIFT_SHOW_WORKING_TIME;
46
51
  rmSync(dir, { recursive: true, force: true });
47
52
  });
48
53
 
49
54
  describe("envDefaults", () => {
50
55
  it("returns 'consistent' when DIFF_LAYOUT is unset", () => {
51
- expect(envDefaults()).toEqual({ diffLayout: "consistent" });
56
+ expect(envDefaults()).toEqual({ diffLayout: "consistent", showWorkingTime: true });
52
57
  });
53
58
 
54
59
  for (const layout of VALID_DIFF_LAYOUTS) {
55
60
  it(`honours DIFF_LAYOUT=${layout}`, () => {
56
61
  process.env.DIFF_LAYOUT = layout;
57
- expect(envDefaults()).toEqual({ diffLayout: layout });
62
+ expect(envDefaults()).toEqual({ diffLayout: layout, showWorkingTime: true });
58
63
  });
59
64
  }
60
65
 
61
66
  it("ignores invalid DIFF_LAYOUT values", () => {
62
67
  process.env.DIFF_LAYOUT = "rainbow";
63
- expect(envDefaults()).toEqual({ diffLayout: "consistent" });
68
+ expect(envDefaults()).toEqual({ diffLayout: "consistent", showWorkingTime: true });
64
69
  });
65
70
 
66
71
  it("is case-insensitive on DIFF_LAYOUT", () => {
67
72
  process.env.DIFF_LAYOUT = "SPLIT";
68
- expect(envDefaults()).toEqual({ diffLayout: "split" });
73
+ expect(envDefaults()).toEqual({ diffLayout: "split", showWorkingTime: true });
74
+ });
75
+
76
+ it("defaults showWorkingTime to true", () => {
77
+ expect(envDefaults().showWorkingTime).toBe(true);
78
+ });
79
+
80
+ it("honours FACELIFT_SHOW_WORKING_TIME=0/off to disable", () => {
81
+ for (const v of ["0", "off", "false"]) {
82
+ process.env.FACELIFT_SHOW_WORKING_TIME = v;
83
+ expect(envDefaults().showWorkingTime).toBe(false);
84
+ }
85
+ });
86
+
87
+ it("ignores invalid FACELIFT_SHOW_WORKING_TIME values", () => {
88
+ process.env.FACELIFT_SHOW_WORKING_TIME = "maybe";
89
+ expect(envDefaults().showWorkingTime).toBe(true);
90
+ });
91
+ });
92
+
93
+ describe("showWorkingTime round-trip", () => {
94
+ it("persists showWorkingTime=false through save/load", () => {
95
+ saveConfig({ diffLayout: "consistent", showWorkingTime: false }, configFile);
96
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: false });
97
+ });
98
+
99
+ it("sanitises a non-boolean showWorkingTime back to the default", () => {
100
+ writeFileSync(configFile, JSON.stringify({ diffLayout: "split", showWorkingTime: "nope" }));
101
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "split", showWorkingTime: true });
69
102
  });
70
103
  });
71
104
 
@@ -74,7 +107,7 @@ describe("loadConfig + saveConfig round-trip", () => {
74
107
  it(`persists diffLayout=${layout}`, () => {
75
108
  saveConfig({ diffLayout: layout } as WierdFaceliftConfig, configFile);
76
109
  expect(existsSync(configFile)).toBe(true);
77
- expect(loadConfig(configFile)).toEqual({ diffLayout: layout });
110
+ expect(loadConfig(configFile)).toEqual({ diffLayout: layout, showWorkingTime: true });
78
111
  });
79
112
  }
80
113
 
@@ -83,23 +116,23 @@ describe("loadConfig + saveConfig round-trip", () => {
83
116
  configFile,
84
117
  JSON.stringify({ diffLayout: "consistent", iconMode: "rainbow", junk: 42 }),
85
118
  );
86
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
119
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
87
120
  });
88
121
 
89
122
  it("rejects invalid diffLayout strings and falls back to default", () => {
90
123
  writeFileSync(configFile, JSON.stringify({ diffLayout: "tutti-frutti" }));
91
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
124
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
92
125
  });
93
126
 
94
127
  it("returns defaults when the file is missing (no write)", () => {
95
128
  expect(existsSync(configFile)).toBe(false);
96
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
129
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
97
130
  expect(existsSync(configFile)).toBe(false);
98
131
  });
99
132
 
100
133
  it("returns defaults when the file is malformed JSON", () => {
101
134
  writeFileSync(configFile, "{ not valid json");
102
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
135
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
103
136
  });
104
137
 
105
138
  it("writes a trailing newline", () => {
@@ -112,22 +145,22 @@ describe("loadOrInitConfig", () => {
112
145
  it("seeds a missing file from env defaults", () => {
113
146
  expect(existsSync(configFile)).toBe(false);
114
147
  const cfg = loadOrInitConfig(configFile);
115
- expect(cfg).toEqual({ diffLayout: "consistent" });
148
+ expect(cfg).toEqual({ diffLayout: "consistent", showWorkingTime: true });
116
149
  expect(existsSync(configFile)).toBe(true);
117
- expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "consistent" });
150
+ expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "consistent", showWorkingTime: true });
118
151
  });
119
152
 
120
153
  it("respects DIFF_LAYOUT when seeding", () => {
121
154
  process.env.DIFF_LAYOUT = "unified";
122
155
  const cfg = loadOrInitConfig(configFile);
123
- expect(cfg).toEqual({ diffLayout: "unified" });
124
- expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "unified" });
156
+ expect(cfg).toEqual({ diffLayout: "unified", showWorkingTime: true });
157
+ expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "unified", showWorkingTime: true });
125
158
  });
126
159
 
127
160
  it("does not overwrite an existing file", () => {
128
161
  writeFileSync(configFile, JSON.stringify({ diffLayout: "split" }));
129
162
  process.env.DIFF_LAYOUT = "unified"; // would override on a fresh seed
130
163
  const cfg = loadOrInitConfig(configFile);
131
- expect(cfg).toEqual({ diffLayout: "split" });
164
+ expect(cfg).toEqual({ diffLayout: "split", showWorkingTime: true });
132
165
  });
133
166
  });
package/config.ts CHANGED
@@ -10,9 +10,9 @@
10
10
  * extension.
11
11
  * - Saves write atomically (one JSON.stringify + trailing newline).
12
12
  *
13
- * The only knob we expose today is `diffLayout`, but the schema is
14
- * designed to accept more fields (icon mode, max preview lines, etc.)
15
- * without breaking older config files.
13
+ * The knobs we expose today are `diffLayout` and `showWorkingTime`, and
14
+ * the schema is designed to accept more fields (icon mode, max preview
15
+ * lines, etc.) without breaking older config files.
16
16
  */
17
17
 
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -47,12 +47,33 @@ export interface WierdFaceliftConfig {
47
47
  * pi-diff behaviour; can produce mixed layouts in one call).
48
48
  */
49
49
  diffLayout: DiffLayoutPreference;
50
+
51
+ /**
52
+ * Show a ticking timer in the streaming "Working…" line and persist
53
+ * the total model working time per agent run as a muted line in chat
54
+ * history (via a durable custom entry). Tool execution time is
55
+ * excluded — only time the model spends streaming is counted.
56
+ *
57
+ * Defaults to `true`; override with env `FACELIFT_SHOW_WORKING_TIME`
58
+ * (`0`/`false`/`off` to disable).
59
+ */
60
+ showWorkingTime: boolean;
50
61
  }
51
62
 
52
63
  function isDiffLayout(value: unknown): value is DiffLayoutPreference {
53
64
  return typeof value === "string" && (VALID_DIFF_LAYOUTS as readonly string[]).includes(value);
54
65
  }
55
66
 
67
+ function parseBool(value: unknown, fallback: boolean): boolean {
68
+ if (typeof value === "boolean") return value;
69
+ if (typeof value === "string") {
70
+ const v = value.trim().toLowerCase();
71
+ if (["0", "false", "off", "no", "disabled"].includes(v)) return false;
72
+ if (["1", "true", "on", "yes", "enabled"].includes(v)) return true;
73
+ }
74
+ return fallback;
75
+ }
76
+
56
77
  /**
57
78
  * Seed defaults from the environment. `DIFF_LAYOUT` is the only env
58
79
  * override today; everything else falls back to the hard-coded default.
@@ -66,6 +87,7 @@ export function envDefaults(): WierdFaceliftConfig {
66
87
  const fromEnv = envLayout && isDiffLayout(envLayout) ? envLayout : undefined;
67
88
  return {
68
89
  diffLayout: fromEnv ?? DEFAULT_DIFF_LAYOUT,
90
+ showWorkingTime: parseBool(process.env.FACELIFT_SHOW_WORKING_TIME, true),
69
91
  };
70
92
  }
71
93
 
@@ -75,6 +97,7 @@ function sanitize(raw: unknown): WierdFaceliftConfig {
75
97
  const obj = raw as Record<string, unknown>;
76
98
  const cfg: WierdFaceliftConfig = { ...defaults };
77
99
  if (isDiffLayout(obj.diffLayout)) cfg.diffLayout = obj.diffLayout;
100
+ if (typeof obj.showWorkingTime === "boolean") cfg.showWorkingTime = obj.showWorkingTime;
78
101
  return cfg;
79
102
  }
80
103
 
@@ -119,6 +142,7 @@ export function saveConfig(
119
142
  }
120
143
  const out: Record<string, unknown> = {
121
144
  diffLayout: cfg.diffLayout,
145
+ showWorkingTime: cfg.showWorkingTime,
122
146
  };
123
147
  writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, "utf-8");
124
148
  return path;
@@ -52,6 +52,8 @@ function loadEditTool(editExec: any) {
52
52
  registerTool: (tool: any) => tools.set(tool.name, tool),
53
53
  registerCommand: () => {},
54
54
  on: () => {},
55
+ registerEntryRenderer: () => {},
56
+ appendEntry: () => {},
55
57
  };
56
58
 
57
59
  piFaceliftExtension(pi, {
@@ -55,6 +55,8 @@ function loadReadTool(readExec: any) {
55
55
  registerTool: (tool: any) => tools.set(tool.name, tool),
56
56
  registerCommand: () => {},
57
57
  on: () => {},
58
+ registerEntryRenderer: () => {},
59
+ appendEntry: () => {},
58
60
  };
59
61
 
60
62
  piFaceliftExtension(pi, {
package/index.ts CHANGED
@@ -83,6 +83,13 @@ import {
83
83
  type DiffLayoutPreference,
84
84
  type WierdFaceliftConfig,
85
85
  } from "./config.ts";
86
+ import {
87
+ WorkingTimeTracker,
88
+ WORKING_TIME_ENTRY,
89
+ workingMessageText,
90
+ workingTimeLine,
91
+ type WorkingTimeEntry,
92
+ } from "./working-time.ts";
86
93
  import {
87
94
  formatDuration,
88
95
  frameBodyLines,
@@ -991,6 +998,11 @@ type PiFaceliftApi = {
991
998
  },
992
999
  ) => void;
993
1000
  on: (event: string, handler: (event: unknown, ctx: SessionContextLike) => Promise<void> | void) => void;
1001
+ registerEntryRenderer: <T = unknown>(
1002
+ customType: string,
1003
+ renderer: (entry: { data?: T }, options: { expanded: boolean }, theme: ThemeLike) => unknown,
1004
+ ) => void;
1005
+ appendEntry: <T = unknown>(customType: string, data?: T) => void;
994
1006
  };
995
1007
  type ReadParams = ReadToolInput;
996
1008
  type BashParams = BashToolInput;
@@ -1136,6 +1148,78 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1136
1148
  }
1137
1149
  })();
1138
1150
 
1151
+ // ===================================================================
1152
+ // Working-time — live timer in the "Working…" line + durable history
1153
+ // -------------------------------------------------------------------
1154
+ // Two metrics per run: `worked` (model streaming only — request
1155
+ // dispatch → assistant message_end, so tools between turns are
1156
+ // excluded, time-to-first-token included) and `total` (wall-clock
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.
1161
+ // ===================================================================
1162
+
1163
+ pi.registerEntryRenderer<WorkingTimeEntry>(WORKING_TIME_ENTRY, (entry, _opts, theme) => {
1164
+ const data = entry.data;
1165
+ if (!data || typeof data.ms !== "number") return undefined;
1166
+ return new TextComponent(workingTimeLine(data, (s) => theme.fg("dim", s)), 0, 0);
1167
+ });
1168
+
1169
+ const workingTracker = new WorkingTimeTracker();
1170
+ let workingTick: NodeJS.Timeout | undefined;
1171
+
1172
+ const stopWorkingTick = (): void => {
1173
+ if (workingTick !== undefined) {
1174
+ clearInterval(workingTick);
1175
+ workingTick = undefined;
1176
+ }
1177
+ };
1178
+
1179
+ const renderWorkingMessage = (ctx: SessionContextLike): void => {
1180
+ if (ctx.hasUI) ctx.ui.setWorkingMessage(workingMessageText(workingTracker.elapsedMs()));
1181
+ };
1182
+
1183
+ // `before_agent_start` fires right after the user submits the prompt,
1184
+ // so it anchors the wall-clock `total` (retries + tools + overhead).
1185
+ pi.on("before_agent_start", () => {
1186
+ if (!faceliftConfig.showWorkingTime) return;
1187
+ workingTracker.beginRun();
1188
+ });
1189
+
1190
+ pi.on("agent_start", () => {
1191
+ if (!faceliftConfig.showWorkingTime) return;
1192
+ // Fallback for flows where before_agent_start didn't fire first.
1193
+ workingTracker.ensureRun();
1194
+ });
1195
+
1196
+ pi.on("before_provider_request", (_event, ctx) => {
1197
+ if (!faceliftConfig.showWorkingTime) return;
1198
+ // Start the segment when the request is dispatched (not on the first
1199
+ // token), so time-to-first-token counts as working time too.
1200
+ workingTracker.beginSegment();
1201
+ stopWorkingTick();
1202
+ renderWorkingMessage(ctx);
1203
+ workingTick = setInterval(() => renderWorkingMessage(ctx), 1000);
1204
+ });
1205
+
1206
+ pi.on("message_end", (event, _ctx) => {
1207
+ if (!faceliftConfig.showWorkingTime) return;
1208
+ const role = (event as { message?: { role?: string } }).message?.role;
1209
+ if (role !== "assistant") return;
1210
+ workingTracker.endSegment();
1211
+ stopWorkingTick();
1212
+ });
1213
+
1214
+ pi.on("agent_settled", (_event, ctx) => {
1215
+ if (!faceliftConfig.showWorkingTime) return;
1216
+ stopWorkingTick();
1217
+ const entry = workingTracker.settle();
1218
+ // Restore pi's default working message for the next run.
1219
+ if (ctx.hasUI) ctx.ui.setWorkingMessage();
1220
+ if (entry) pi.appendEntry(WORKING_TIME_ENTRY, entry);
1221
+ });
1222
+
1139
1223
  // ===================================================================
1140
1224
  // read — syntax-highlighted file content
1141
1225
  // ===================================================================
@@ -1735,6 +1819,15 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1735
1819
  options: VALID_DIFF_LAYOUTS,
1736
1820
  optionLabels: diffLayoutLabels,
1737
1821
  },
1822
+ {
1823
+ key: "showWorkingTime",
1824
+ type: "boolean",
1825
+ label: "Working timer",
1826
+ description:
1827
+ "Show a ticking timer in the streaming \u201cWorking\u2026\u201d line and persist each agent run\u2019s total model time (tools excluded) as a muted line in chat history.",
1828
+ value: faceliftConfig.showWorkingTime,
1829
+ default: true,
1830
+ },
1738
1831
  ];
1739
1832
  await openSettingsModal(extCtx, {
1740
1833
  title: "@wierdbytes/pi-facelift",
@@ -1744,6 +1837,10 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1744
1837
  faceliftConfig = { ...faceliftConfig, diffLayout: value as DiffLayoutPreference };
1745
1838
  persistFaceliftConfig(extCtx);
1746
1839
  }
1840
+ if (key === "showWorkingTime") {
1841
+ faceliftConfig = { ...faceliftConfig, showWorkingTime: value as boolean };
1842
+ persistFaceliftConfig(extCtx);
1843
+ }
1747
1844
  },
1748
1845
  });
1749
1846
  };
@@ -1752,6 +1849,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1752
1849
  const lines = [
1753
1850
  `config: ${getFaceliftConfigPath()}`,
1754
1851
  `diffLayout: ${faceliftConfig.diffLayout}`,
1852
+ `workingTime: ${faceliftConfig.showWorkingTime ? "on" : "off"}`,
1755
1853
  ];
1756
1854
  extCtx.ui.notify(`@wierdbytes/pi-facelift\n${lines.join("\n")}`, "info");
1757
1855
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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": [
@@ -73,6 +73,8 @@ function loadReadTool(readExec: any) {
73
73
  registerTool: (tool: any) => tools.set(tool.name, tool),
74
74
  registerCommand: () => {},
75
75
  on: () => {},
76
+ registerEntryRenderer: () => {},
77
+ appendEntry: () => {},
76
78
  };
77
79
 
78
80
  piFaceliftExtension(pi, {
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Tests for the working-time tracker and its extension wiring.
3
+ *
4
+ * The tracker measures two metrics: `worked` (model streaming only,
5
+ * tools excluded) and `totalMs` (wall-clock from user prompt to settle,
6
+ * retries/tools/overhead included). The wiring drives it from pi
7
+ * lifecycle events: before_agent_start / agent_start /
8
+ * before_provider_request / message_end / agent_settled. The durable
9
+ * history line is produced via pi.appendEntry + registerEntryRenderer.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ import piFaceliftExtension from "./index.ts";
15
+ import {
16
+ formatClock,
17
+ WorkingTimeTracker,
18
+ WORKING_TIME_ENTRY,
19
+ workingMessageText,
20
+ workingTimeLine,
21
+ type WorkingTimeEntry,
22
+ } from "./working-time.ts";
23
+
24
+ describe("WorkingTimeTracker", () => {
25
+ it("accumulates a single streaming segment", () => {
26
+ let now = 1000;
27
+ const t = new WorkingTimeTracker(() => now);
28
+ t.beginRun();
29
+ now = 2000;
30
+ t.beginSegment();
31
+ now = 4500;
32
+ t.endSegment();
33
+ expect(t.elapsedMs()).toBe(2500);
34
+ const entry = t.settle();
35
+ expect(entry?.ms).toBe(2500);
36
+ expect(entry?.startedAt).toBe(1000);
37
+ });
38
+
39
+ it("measures totalMs as wall-clock across the whole run (incl. tool gap)", () => {
40
+ let now = 0;
41
+ const t = new WorkingTimeTracker(() => now);
42
+ t.beginRun();
43
+ t.beginSegment();
44
+ now = 1000;
45
+ t.endSegment();
46
+ // tool + overhead from 1000..9000 counts toward total, not worked
47
+ now = 9000;
48
+ t.beginSegment();
49
+ now = 10000;
50
+ t.endSegment();
51
+ const entry = t.settle();
52
+ expect(entry?.ms).toBe(2000);
53
+ expect(entry?.totalMs).toBe(10000);
54
+ });
55
+
56
+ it("ensureRun does not reset an in-progress run", () => {
57
+ let now = 0;
58
+ const t = new WorkingTimeTracker(() => now);
59
+ t.beginRun();
60
+ t.beginSegment();
61
+ now = 500;
62
+ t.endSegment();
63
+ now = 1000;
64
+ t.ensureRun(); // agent_start fallback mid-run — must not reset
65
+ expect(t.elapsedMs()).toBe(500);
66
+ now = 3000;
67
+ expect(t.settle()?.totalMs).toBe(3000);
68
+ });
69
+
70
+ it("excludes the gap between segments (tool execution time)", () => {
71
+ let now = 0;
72
+ const t = new WorkingTimeTracker(() => now);
73
+ t.beginRun();
74
+ // first model segment: 0..1000
75
+ t.beginSegment();
76
+ now = 1000;
77
+ t.endSegment();
78
+ // "tool" runs from 1000..9000 — must NOT count
79
+ now = 9000;
80
+ // second model segment: 9000..10000
81
+ t.beginSegment();
82
+ now = 10000;
83
+ t.endSegment();
84
+ expect(t.settle()?.ms).toBe(2000);
85
+ });
86
+
87
+ it("includes an open segment in elapsedMs while streaming", () => {
88
+ let now = 0;
89
+ const t = new WorkingTimeTracker(() => now);
90
+ t.beginRun();
91
+ t.beginSegment();
92
+ now = 3000;
93
+ expect(t.elapsedMs()).toBe(3000);
94
+ expect(t.isActive()).toBe(true);
95
+ });
96
+
97
+ it("returns undefined for a zero-time run", () => {
98
+ const t = new WorkingTimeTracker(() => 0);
99
+ t.beginRun();
100
+ expect(t.settle()).toBeUndefined();
101
+ });
102
+
103
+ it("resets state after settle", () => {
104
+ let now = 0;
105
+ const t = new WorkingTimeTracker(() => now);
106
+ t.beginRun();
107
+ t.beginSegment();
108
+ now = 1000;
109
+ t.endSegment();
110
+ t.settle();
111
+ expect(t.isActive()).toBe(false);
112
+ expect(t.elapsedMs()).toBe(0);
113
+ });
114
+ });
115
+
116
+ describe("formatClock", () => {
117
+ it("keeps one decimal below a minute", () => {
118
+ expect(formatClock(0)).toBe("0.0s");
119
+ expect(formatClock(45_200)).toBe("45.2s");
120
+ expect(formatClock(59_900)).toBe("59.9s");
121
+ });
122
+
123
+ it("formats minutes and seconds, always ending in seconds", () => {
124
+ expect(formatClock(60_000)).toBe("1m0s");
125
+ expect(formatClock(75_000)).toBe("1m15s");
126
+ expect(formatClock(720_000)).toBe("12m0s"); // 0h12m0s -> 12m0s
127
+ });
128
+
129
+ it("formats hours, keeping interior zeros", () => {
130
+ expect(formatClock(4_356_000)).toBe("1h12m36s");
131
+ expect(formatClock(3_605_000)).toBe("1h0m5s");
132
+ expect(formatClock(3_600_000)).toBe("1h0m0s");
133
+ });
134
+
135
+ it("drops the leading zero hours part", () => {
136
+ expect(formatClock(65_000)).toBe("1m5s"); // 0h1m5s -> 1m5s
137
+ });
138
+
139
+ it("never returns a negative duration", () => {
140
+ expect(formatClock(-5000)).toBe("0.0s");
141
+ });
142
+ });
143
+
144
+ 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");
149
+ });
150
+
151
+ it("renders a muted history line with worked + total", () => {
152
+ 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>");
155
+ });
156
+ });
157
+
158
+ // --- extension wiring ---------------------------------------------------
159
+
160
+ class MockText {
161
+ private text: string;
162
+ constructor(text = "", _x = 0, _y = 0) {
163
+ this.text = text;
164
+ }
165
+ setText(value: string) {
166
+ this.text = value;
167
+ }
168
+ getText() {
169
+ return this.text;
170
+ }
171
+ }
172
+
173
+ function mockToolFactory(exec: any) {
174
+ return (_cwd: string) => ({
175
+ name: "mock",
176
+ description: "mock",
177
+ parameters: { type: "object", properties: {} },
178
+ execute: exec,
179
+ });
180
+ }
181
+
182
+ interface MockCtx {
183
+ hasUI: boolean;
184
+ ui: { setWorkingMessage: ReturnType<typeof vi.fn> };
185
+ }
186
+
187
+ function loadExtension() {
188
+ const handlers = new Map<string, (event: unknown, ctx: MockCtx) => void>();
189
+ const entryRenderers = new Map<string, (entry: any, opts: any, theme: any) => unknown>();
190
+ const entries: Array<{ type: string; data: unknown }> = [];
191
+
192
+ const pi = {
193
+ registerTool: () => {},
194
+ registerCommand: () => {},
195
+ on: (name: string, h: (event: unknown, ctx: MockCtx) => void) => handlers.set(name, h),
196
+ registerEntryRenderer: (type: string, r: any) => entryRenderers.set(type, r),
197
+ appendEntry: (type: string, data: unknown) => entries.push({ type, data }),
198
+ };
199
+
200
+ const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
201
+ piFaceliftExtension(pi as any, {
202
+ sdk: {
203
+ createReadToolDefinition: mockToolFactory(noopExec),
204
+ getAgentDir: () => "/tmp/pi-facelift-test",
205
+ },
206
+ TextComponent: MockText as any,
207
+ });
208
+
209
+ const ctx: MockCtx = { hasUI: true, ui: { setWorkingMessage: vi.fn() } };
210
+ const fire = (name: string, event: unknown) => handlers.get(name)?.(event, ctx);
211
+ return { fire, ctx, entries, entryRenderers };
212
+ }
213
+
214
+ const asst = { message: { role: "assistant" } };
215
+ const user = { message: { role: "user" } };
216
+
217
+ describe("working-time wiring", () => {
218
+ beforeEach(() => vi.restoreAllMocks());
219
+
220
+ it("worked counts request→message_end; total counts the whole run", () => {
221
+ vi.useFakeTimers();
222
+ const { fire, entries } = loadExtension();
223
+
224
+ fire("before_agent_start", {}); // anchors the total clock
225
+ vi.advanceTimersByTime(500); // pre-loop overhead
226
+ fire("agent_start", {});
227
+ fire("before_provider_request", {});
228
+ vi.advanceTimersByTime(5000);
229
+ fire("message_end", asst);
230
+ // tool gap — counts toward total, NOT worked
231
+ vi.advanceTimersByTime(3000);
232
+ fire("before_provider_request", {});
233
+ vi.advanceTimersByTime(2000);
234
+ fire("message_end", asst);
235
+ fire("agent_settled", {});
236
+
237
+ vi.useRealTimers();
238
+ expect(entries).toHaveLength(1);
239
+ expect(entries[0].type).toBe(WORKING_TIME_ENTRY);
240
+ const data = entries[0].data as WorkingTimeEntry;
241
+ expect(data.ms).toBe(7000);
242
+ expect(data.totalMs).toBe(10500); // 500 + 5000 + 3000 + 2000
243
+ });
244
+
245
+ it("ignores non-assistant message_end (does not close the segment early)", () => {
246
+ vi.useFakeTimers();
247
+ const { fire, entries } = loadExtension();
248
+ fire("before_agent_start", {});
249
+ fire("agent_start", {});
250
+ fire("before_provider_request", {});
251
+ vi.advanceTimersByTime(1000);
252
+ fire("message_end", user); // stray non-assistant end — ignored
253
+ vi.advanceTimersByTime(1000);
254
+ fire("message_end", asst);
255
+ fire("agent_settled", {});
256
+ vi.useRealTimers();
257
+ expect(entries).toHaveLength(1);
258
+ expect((entries[0].data as WorkingTimeEntry).ms).toBe(2000);
259
+ });
260
+
261
+ it("updates the live working message from request send and restores on settle", () => {
262
+ vi.useFakeTimers();
263
+ const { fire, ctx } = loadExtension();
264
+ fire("before_agent_start", {});
265
+ fire("agent_start", {});
266
+ fire("before_provider_request", {});
267
+ vi.advanceTimersByTime(2000);
268
+ const calls = ctx.ui.setWorkingMessage.mock.calls.map((c) => c[0]);
269
+ expect(calls[0]).toBe("Working... 0s");
270
+ expect(calls).toContain("Working... 2s");
271
+ fire("message_end", asst);
272
+ fire("agent_settled", {});
273
+ vi.useRealTimers();
274
+ // last call restores the default (undefined)
275
+ const last = ctx.ui.setWorkingMessage.mock.calls.at(-1);
276
+ expect(last?.[0]).toBeUndefined();
277
+ });
278
+
279
+ it("registers an entry renderer that produces a dim worked+total line", () => {
280
+ const { entryRenderers } = loadExtension();
281
+ const renderer = entryRenderers.get(WORKING_TIME_ENTRY);
282
+ expect(renderer).toBeDefined();
283
+ const theme = { fg: (_k: string, s: string) => `dim(${s})` };
284
+ const out = renderer!(
285
+ { data: { ms: 1500, totalMs: 4000, startedAt: 0, endedAt: 0 } },
286
+ { expanded: false },
287
+ theme,
288
+ ) as MockText;
289
+ expect(out.getText()).toBe("dim(⏱ worked 1.5s (total: 4.0s))");
290
+ });
291
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Working-time tracking for @wierdbytes/pi-facelift.
3
+ *
4
+ * Two metrics are measured per agent run:
5
+ *
6
+ * • `worked` — model-only time: from the moment a provider request is
7
+ * dispatched (`before_provider_request`, so time-to-first-token
8
+ * counts too) until the assistant message ends. Tool execution is
9
+ * deliberately excluded — tools run between an assistant
10
+ * `message_end` and the next request, so measuring only
11
+ * request→message-end lifetimes naturally drops them.
12
+ *
13
+ * • `total` — wall-clock time from when the user submits the prompt
14
+ * (`before_agent_start`) until the run settles (`agent_settled`).
15
+ * Retries, tool calls, and every other overhead are included.
16
+ *
17
+ * Two consumers:
18
+ * • Live: while the current assistant message streams, a 1s interval
19
+ * calls `setWorkingMessage` with a ticking timer (spinner frames are
20
+ * left untouched — we only replace the message text).
21
+ * • Durable: on `agent_settled` both metrics are persisted via
22
+ * `pi.appendEntry` as a `WorkingTimeEntry` and rendered in chat
23
+ * history by the entry renderer registered in `index.ts`.
24
+ *
25
+ * The class is pure logic (an injectable `now()` clock) so it is fully
26
+ * unit-testable without a running pi session.
27
+ */
28
+
29
+ /** Custom entry type used with `pi.appendEntry` / `registerEntryRenderer`. */
30
+ export const WORKING_TIME_ENTRY = "facelift-working-time";
31
+
32
+ /** Payload stored on the durable working-time entry. */
33
+ export interface WorkingTimeEntry {
34
+ /** Model-only streaming time for the run, in milliseconds (tools excluded). */
35
+ ms: number;
36
+ /** Wall-clock time from user prompt to final response, in milliseconds. */
37
+ totalMs: number;
38
+ /** Epoch ms when the run started (user prompt submitted). */
39
+ startedAt: number;
40
+ /** Epoch ms when the run settled. */
41
+ endedAt: number;
42
+ }
43
+
44
+ /**
45
+ * Tracks model streaming time and total wall-clock time across the turns
46
+ * of a single agent run.
47
+ *
48
+ * Lifecycle (driven by pi events):
49
+ * before_agent_start → beginRun() (total clock starts here)
50
+ * agent_start → ensureRun() (fallback if before_agent_start skipped)
51
+ * before_provider_request → beginSegment() + start the live ticker
52
+ * message_end (asst) → endSegment() + stop the ticker
53
+ * agent_settled → settle() ⇒ { ms, totalMs }, then reset
54
+ *
55
+ * Retries / auto-compaction fire their own agent_start but pi only emits
56
+ * `agent_settled` when it truly stops, so a run is measured end-to-end.
57
+ */
58
+ export class WorkingTimeTracker {
59
+ private readonly now: () => number;
60
+ private accumulatorMs = 0;
61
+ private segmentStart: number | undefined;
62
+ private runStart: number | undefined;
63
+
64
+ constructor(now: () => number = () => Date.now()) {
65
+ this.now = now;
66
+ }
67
+
68
+ /** Start a fresh run, discarding any prior accumulated time. */
69
+ beginRun(): void {
70
+ this.accumulatorMs = 0;
71
+ this.segmentStart = undefined;
72
+ this.runStart = this.now();
73
+ }
74
+
75
+ /** Start a run only if one isn't already in progress (idempotent). */
76
+ ensureRun(): void {
77
+ if (this.runStart === undefined) this.beginRun();
78
+ }
79
+
80
+ /** Begin timing an assistant streaming segment. */
81
+ beginSegment(): void {
82
+ this.ensureRun();
83
+ if (this.segmentStart === undefined) this.segmentStart = this.now();
84
+ }
85
+
86
+ /** Close the current segment, folding its duration into the total. */
87
+ endSegment(): void {
88
+ if (this.segmentStart !== undefined) {
89
+ this.accumulatorMs += Math.max(0, this.now() - this.segmentStart);
90
+ this.segmentStart = undefined;
91
+ }
92
+ }
93
+
94
+ /** Milliseconds accumulated so far, including any open segment. */
95
+ elapsedMs(): number {
96
+ const open = this.segmentStart !== undefined ? Math.max(0, this.now() - this.segmentStart) : 0;
97
+ return this.accumulatorMs + open;
98
+ }
99
+
100
+ /** True while a run is in progress (between beginRun and settle). */
101
+ isActive(): boolean {
102
+ return this.runStart !== undefined;
103
+ }
104
+
105
+ /**
106
+ * Finalize the run and return the durable entry, or `undefined` when
107
+ * no measurable time accrued (guards against junk 0ms entries).
108
+ * `totalMs` spans the whole run (retries, tools, overhead included).
109
+ */
110
+ settle(): WorkingTimeEntry | undefined {
111
+ this.endSegment();
112
+ const endedAt = this.now();
113
+ const ms = this.accumulatorMs;
114
+ const startedAt = this.runStart;
115
+ this.accumulatorMs = 0;
116
+ this.segmentStart = undefined;
117
+ this.runStart = undefined;
118
+ if (startedAt === undefined || ms <= 0) return undefined;
119
+ return { ms, totalMs: Math.max(0, endedAt - startedAt), startedAt, endedAt };
120
+ }
121
+ }
122
+
123
+ /**
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.
128
+ */
129
+ export function workingMessageText(elapsedMs: number): string {
130
+ const sec = Math.max(0, Math.floor(elapsedMs / 1000));
131
+ return `Working... ${sec}s`;
132
+ }
133
+
134
+ /**
135
+ * Format a duration as a compact clock string.
136
+ *
137
+ * - Sub-minute durations keep one decimal: `45.2s`.
138
+ * - Longer durations use integer `h`/`m`/`s` parts and always end in
139
+ * seconds. Leading zero parts are dropped — `0h12m0s` → `12m0s`,
140
+ * `0h0m5s` → `5s` — but once a part is present every following part is
141
+ * shown, interior zeros included (`1h0m5s`, `1h0m0s`).
142
+ */
143
+ export function formatClock(ms: number): string {
144
+ const totalSec = Math.max(0, ms) / 1000;
145
+ if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
146
+ const totalSecInt = Math.floor(totalSec);
147
+ const hours = Math.floor(totalSecInt / 3600);
148
+ const min = Math.floor((totalSecInt % 3600) / 60);
149
+ const sec = totalSecInt % 60;
150
+ let out = "";
151
+ if (hours > 0) out += `${hours}h`;
152
+ if (hours > 0 || min > 0) out += `${min}m`;
153
+ return `${out}${sec}s`;
154
+ }
155
+
156
+ /**
157
+ * Render the durable entry body as a plain muted line (no frame):
158
+ * `⏱ worked 45.2s (total: 1m15s)`.
159
+ */
160
+ export function workingTimeLine(entry: WorkingTimeEntry, dim: (s: string) => string): string {
161
+ return dim(`⏱ worked ${formatClock(entry.ms)} (total: ${formatClock(entry.totalMs)})`);
162
+ }
@@ -56,6 +56,8 @@ function loadTools(opts: { writeExec?: any; editExec?: any } = {}) {
56
56
  registerTool: (tool: any) => tools.set(tool.name, tool),
57
57
  registerCommand: () => {},
58
58
  on: () => {},
59
+ registerEntryRenderer: () => {},
60
+ appendEntry: () => {},
59
61
  };
60
62
 
61
63
  piFaceliftExtension(pi, {