@wierdbytes/pi-facelift 0.6.5 → 0.7.0

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.7.0
4
+
5
+ - **Change:** still accent `●` instead of pi's animated spinner in the `Working...` line — no ~12×/s terminal repaints while the model streams; `animateWorkingIndicator` in `/facelift` brings the animation back.
6
+
7
+ ## 0.6.6
8
+
9
+ - **Fix:** prompt-cache warm-ups no longer inflate `worked` time / deflate `tps` or start a phantom working timer while idle.
10
+
3
11
  ## 0.6.5
4
12
 
5
13
  - **Fix:** pi crashing with "extension ctx is stale" when the session was replaced or reloaded while the working timer was ticking.
package/README.md CHANGED
@@ -49,7 +49,8 @@ tool output.
49
49
  `⏱ worked 45.2s (total: 1m15s) · tps: 756`
50
50
 
51
51
  - `worked` — model-only time (request dispatch → response end, so
52
- time-to-first-token counts; tool execution is excluded).
52
+ time-to-first-token counts; tool execution and pi's prompt-cache
53
+ warm-up requests are excluded).
53
54
  - `total` — wall-clock time from your prompt to the final response,
54
55
  including retries, tool calls, and all other overhead.
55
56
  - `tps` — output tokens per second over the `worked` time. The live
@@ -61,6 +62,11 @@ tool output.
61
62
  (`0h12m0s` → `12m0s`); sub-minute values keep one decimal (`45.2s`).
62
63
 
63
64
  Toggle via `showWorkingTime` in `/facelift`.
65
+ - **still working indicator** — pi's braille spinner in front of the
66
+ `Working...` line is replaced with a still accent `●`, so the terminal
67
+ isn't repainted ~12×/s while the model streams (GPU-rendered hosts such
68
+ as Herdr redraw the whole window for every frame). Turn the animation
69
+ back on via `animateWorkingIndicator` in `/facelift`.
64
70
 
65
71
  ## Install
66
72
 
@@ -127,7 +133,11 @@ Current schema:
127
133
  // each run's model time (`worked`, tools excluded) and wall-clock time
128
134
  // (`total`, retries + tools + overhead) as a muted line in chat
129
135
  // history. Default true.
130
- "showWorkingTime": true
136
+ "showWorkingTime": true,
137
+
138
+ // Animate the spinner in front of the "Working..." line (pi's default,
139
+ // redrawn every 80ms). Default false: a still accent "●".
140
+ "animateWorkingIndicator": false
131
141
  }
132
142
  ```
133
143
 
@@ -146,6 +156,7 @@ icons, image protocols) or boot-time toggles:
146
156
  | `FACELIFT_IMAGE_PROTOCOL` | auto | Force `kitty` / `iterm2` / `none`. Auto-detected from `$TERM_PROGRAM` etc. |
147
157
  | `DIFF_LAYOUT` | (none) | One-shot override for `diffLayout` on first-run seeding. |
148
158
  | `FACELIFT_SHOW_WORKING_TIME` | `true` | One-shot override for `showWorkingTime` on first-run seeding (`0`/`off` ⇒ off). |
159
+ | `FACELIFT_ANIMATE_WORKING` | `false` | Default for `animateWorkingIndicator` when the config doesn't set it (`1`/`on` ⇒ animated). |
149
160
 
150
161
  ## Development
151
162
 
package/config.test.ts CHANGED
@@ -40,6 +40,8 @@ beforeEach(() => {
40
40
  delete process.env.DIFF_LAYOUT;
41
41
  savedEnv.FACELIFT_SHOW_WORKING_TIME = process.env.FACELIFT_SHOW_WORKING_TIME;
42
42
  delete process.env.FACELIFT_SHOW_WORKING_TIME;
43
+ savedEnv.FACELIFT_ANIMATE_WORKING = process.env.FACELIFT_ANIMATE_WORKING;
44
+ delete process.env.FACELIFT_ANIMATE_WORKING;
43
45
  });
44
46
 
45
47
  afterEach(() => {
@@ -48,29 +50,32 @@ afterEach(() => {
48
50
  if (savedEnv.FACELIFT_SHOW_WORKING_TIME !== undefined)
49
51
  process.env.FACELIFT_SHOW_WORKING_TIME = savedEnv.FACELIFT_SHOW_WORKING_TIME;
50
52
  else delete process.env.FACELIFT_SHOW_WORKING_TIME;
53
+ if (savedEnv.FACELIFT_ANIMATE_WORKING !== undefined)
54
+ process.env.FACELIFT_ANIMATE_WORKING = savedEnv.FACELIFT_ANIMATE_WORKING;
55
+ else delete process.env.FACELIFT_ANIMATE_WORKING;
51
56
  rmSync(dir, { recursive: true, force: true });
52
57
  });
53
58
 
54
59
  describe("envDefaults", () => {
55
60
  it("returns 'consistent' when DIFF_LAYOUT is unset", () => {
56
- expect(envDefaults()).toEqual({ diffLayout: "consistent", showWorkingTime: true });
61
+ expect(envDefaults()).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
57
62
  });
58
63
 
59
64
  for (const layout of VALID_DIFF_LAYOUTS) {
60
65
  it(`honours DIFF_LAYOUT=${layout}`, () => {
61
66
  process.env.DIFF_LAYOUT = layout;
62
- expect(envDefaults()).toEqual({ diffLayout: layout, showWorkingTime: true });
67
+ expect(envDefaults()).toEqual({ diffLayout: layout, showWorkingTime: true, animateWorkingIndicator: false });
63
68
  });
64
69
  }
65
70
 
66
71
  it("ignores invalid DIFF_LAYOUT values", () => {
67
72
  process.env.DIFF_LAYOUT = "rainbow";
68
- expect(envDefaults()).toEqual({ diffLayout: "consistent", showWorkingTime: true });
73
+ expect(envDefaults()).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
69
74
  });
70
75
 
71
76
  it("is case-insensitive on DIFF_LAYOUT", () => {
72
77
  process.env.DIFF_LAYOUT = "SPLIT";
73
- expect(envDefaults()).toEqual({ diffLayout: "split", showWorkingTime: true });
78
+ expect(envDefaults()).toEqual({ diffLayout: "split", showWorkingTime: true, animateWorkingIndicator: false });
74
79
  });
75
80
 
76
81
  it("defaults showWorkingTime to true", () => {
@@ -90,15 +95,43 @@ describe("envDefaults", () => {
90
95
  });
91
96
  });
92
97
 
98
+ describe("animateWorkingIndicator", () => {
99
+ it("defaults to false (a still indicator)", () => {
100
+ expect(envDefaults().animateWorkingIndicator).toBe(false);
101
+ });
102
+
103
+ it("honours FACELIFT_ANIMATE_WORKING=1/on/true to animate", () => {
104
+ for (const v of ["1", "on", "true"]) {
105
+ process.env.FACELIFT_ANIMATE_WORKING = v;
106
+ expect(envDefaults().animateWorkingIndicator).toBe(true);
107
+ }
108
+ });
109
+
110
+ it("persists animateWorkingIndicator=true through save/load", () => {
111
+ saveConfig({ diffLayout: "split", showWorkingTime: true, animateWorkingIndicator: true }, configFile);
112
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "split", showWorkingTime: true, animateWorkingIndicator: true });
113
+ });
114
+
115
+ it("sanitises a non-boolean value back to the default", () => {
116
+ writeFileSync(configFile, JSON.stringify({ diffLayout: "split", animateWorkingIndicator: "yes" }));
117
+ expect(loadConfig(configFile).animateWorkingIndicator).toBe(false);
118
+ });
119
+
120
+ it("keeps older config files (no field) on the default", () => {
121
+ writeFileSync(configFile, JSON.stringify({ diffLayout: "split", showWorkingTime: false }));
122
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "split", showWorkingTime: false, animateWorkingIndicator: false });
123
+ });
124
+ });
125
+
93
126
  describe("showWorkingTime round-trip", () => {
94
127
  it("persists showWorkingTime=false through save/load", () => {
95
- saveConfig({ diffLayout: "consistent", showWorkingTime: false }, configFile);
96
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: false });
128
+ saveConfig({ diffLayout: "consistent", showWorkingTime: false, animateWorkingIndicator: false }, configFile);
129
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: false, animateWorkingIndicator: false });
97
130
  });
98
131
 
99
132
  it("sanitises a non-boolean showWorkingTime back to the default", () => {
100
133
  writeFileSync(configFile, JSON.stringify({ diffLayout: "split", showWorkingTime: "nope" }));
101
- expect(loadConfig(configFile)).toEqual({ diffLayout: "split", showWorkingTime: true });
134
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "split", showWorkingTime: true, animateWorkingIndicator: false });
102
135
  });
103
136
  });
104
137
 
@@ -107,7 +140,7 @@ describe("loadConfig + saveConfig round-trip", () => {
107
140
  it(`persists diffLayout=${layout}`, () => {
108
141
  saveConfig({ diffLayout: layout } as WierdFaceliftConfig, configFile);
109
142
  expect(existsSync(configFile)).toBe(true);
110
- expect(loadConfig(configFile)).toEqual({ diffLayout: layout, showWorkingTime: true });
143
+ expect(loadConfig(configFile)).toEqual({ diffLayout: layout, showWorkingTime: true, animateWorkingIndicator: false });
111
144
  });
112
145
  }
113
146
 
@@ -116,23 +149,23 @@ describe("loadConfig + saveConfig round-trip", () => {
116
149
  configFile,
117
150
  JSON.stringify({ diffLayout: "consistent", iconMode: "rainbow", junk: 42 }),
118
151
  );
119
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
152
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
120
153
  });
121
154
 
122
155
  it("rejects invalid diffLayout strings and falls back to default", () => {
123
156
  writeFileSync(configFile, JSON.stringify({ diffLayout: "tutti-frutti" }));
124
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
157
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
125
158
  });
126
159
 
127
160
  it("returns defaults when the file is missing (no write)", () => {
128
161
  expect(existsSync(configFile)).toBe(false);
129
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
162
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
130
163
  expect(existsSync(configFile)).toBe(false);
131
164
  });
132
165
 
133
166
  it("returns defaults when the file is malformed JSON", () => {
134
167
  writeFileSync(configFile, "{ not valid json");
135
- expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true });
168
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
136
169
  });
137
170
 
138
171
  it("writes a trailing newline", () => {
@@ -145,22 +178,22 @@ describe("loadOrInitConfig", () => {
145
178
  it("seeds a missing file from env defaults", () => {
146
179
  expect(existsSync(configFile)).toBe(false);
147
180
  const cfg = loadOrInitConfig(configFile);
148
- expect(cfg).toEqual({ diffLayout: "consistent", showWorkingTime: true });
181
+ expect(cfg).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
149
182
  expect(existsSync(configFile)).toBe(true);
150
- expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "consistent", showWorkingTime: true });
183
+ expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "consistent", showWorkingTime: true, animateWorkingIndicator: false });
151
184
  });
152
185
 
153
186
  it("respects DIFF_LAYOUT when seeding", () => {
154
187
  process.env.DIFF_LAYOUT = "unified";
155
188
  const cfg = loadOrInitConfig(configFile);
156
- expect(cfg).toEqual({ diffLayout: "unified", showWorkingTime: true });
157
- expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "unified", showWorkingTime: true });
189
+ expect(cfg).toEqual({ diffLayout: "unified", showWorkingTime: true, animateWorkingIndicator: false });
190
+ expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "unified", showWorkingTime: true, animateWorkingIndicator: false });
158
191
  });
159
192
 
160
193
  it("does not overwrite an existing file", () => {
161
194
  writeFileSync(configFile, JSON.stringify({ diffLayout: "split" }));
162
195
  process.env.DIFF_LAYOUT = "unified"; // would override on a fresh seed
163
196
  const cfg = loadOrInitConfig(configFile);
164
- expect(cfg).toEqual({ diffLayout: "split", showWorkingTime: true });
197
+ expect(cfg).toEqual({ diffLayout: "split", showWorkingTime: true, animateWorkingIndicator: false });
165
198
  });
166
199
  });
package/config.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * extension.
11
11
  * - Saves write atomically (one JSON.stringify + trailing newline).
12
12
  *
13
- * The knobs we expose today are `diffLayout` and `showWorkingTime`, and
13
+ * The knobs we expose today are `diffLayout`, `showWorkingTime` and
14
+ * `animateWorkingIndicator`, and
14
15
  * the schema is designed to accept more fields (icon mode, max preview
15
16
  * lines, etc.) without breaking older config files.
16
17
  */
@@ -58,6 +59,18 @@ export interface WierdFaceliftConfig {
58
59
  * (`0`/`false`/`off` to disable).
59
60
  */
60
61
  showWorkingTime: boolean;
62
+
63
+ /**
64
+ * Animate the spinner in front of the streaming "Working…" line (pi's
65
+ * default: a braille spinner redrawn every 80ms). Off ⇒ a still accent
66
+ * `●` instead, so an idle-looking terminal isn't repainted 12×/s while
67
+ * the model streams — GPU-rendered hosts (e.g. Herdr) redraw the whole
68
+ * window for each of those frames.
69
+ *
70
+ * Defaults to `false`; override with env `FACELIFT_ANIMATE_WORKING`
71
+ * (`1`/`true`/`on` to animate).
72
+ */
73
+ animateWorkingIndicator: boolean;
61
74
  }
62
75
 
63
76
  function isDiffLayout(value: unknown): value is DiffLayoutPreference {
@@ -88,6 +101,7 @@ export function envDefaults(): WierdFaceliftConfig {
88
101
  return {
89
102
  diffLayout: fromEnv ?? DEFAULT_DIFF_LAYOUT,
90
103
  showWorkingTime: parseBool(process.env.FACELIFT_SHOW_WORKING_TIME, true),
104
+ animateWorkingIndicator: parseBool(process.env.FACELIFT_ANIMATE_WORKING, false),
91
105
  };
92
106
  }
93
107
 
@@ -98,6 +112,7 @@ function sanitize(raw: unknown): WierdFaceliftConfig {
98
112
  const cfg: WierdFaceliftConfig = { ...defaults };
99
113
  if (isDiffLayout(obj.diffLayout)) cfg.diffLayout = obj.diffLayout;
100
114
  if (typeof obj.showWorkingTime === "boolean") cfg.showWorkingTime = obj.showWorkingTime;
115
+ if (typeof obj.animateWorkingIndicator === "boolean") cfg.animateWorkingIndicator = obj.animateWorkingIndicator;
101
116
  return cfg;
102
117
  }
103
118
 
@@ -143,6 +158,7 @@ export function saveConfig(
143
158
  const out: Record<string, unknown> = {
144
159
  diffLayout: cfg.diffLayout,
145
160
  showWorkingTime: cfg.showWorkingTime,
161
+ animateWorkingIndicator: cfg.animateWorkingIndicator,
146
162
  };
147
163
  writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, "utf-8");
148
164
  return path;
package/index.ts CHANGED
@@ -1211,11 +1211,22 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1211
1211
  workingTracker.ensureRun();
1212
1212
  });
1213
1213
 
1214
+ // pi emits `turn_start` (awaited) right before every agent request, so it
1215
+ // arms the tracker. Cache-warmer replays also fire `before_provider_request`
1216
+ // but arrive outside this window (during tools or after settle) and never
1217
+ // get a `message_end` — unguarded they would hold a segment open forever.
1218
+ pi.on("turn_start", () => {
1219
+ if (!faceliftConfig.showWorkingTime) return;
1220
+ workingTracker.armRequest();
1221
+ });
1222
+
1214
1223
  pi.on("before_provider_request", (_event, ctx) => {
1215
1224
  if (!faceliftConfig.showWorkingTime) return;
1216
1225
  // Start the segment when the request is dispatched (not on the first
1217
- // token), so time-to-first-token counts as working time too.
1218
- workingTracker.beginSegment();
1226
+ // token), so time-to-first-token counts as working time too. A `false`
1227
+ // means a cache warm-up (not armed) or a repeat payload hook for an
1228
+ // already-open request — leave the ticker alone.
1229
+ if (!workingTracker.beginSegment()) return;
1219
1230
  stopWorkingTick();
1220
1231
  renderWorkingMessage(ctx);
1221
1232
  workingTick = setInterval(() => renderWorkingMessage(ctx), 1000);
@@ -1247,6 +1258,31 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1247
1258
  if (entry) pi.appendEntry(WORKING_TIME_ENTRY, entry);
1248
1259
  });
1249
1260
 
1261
+ // ===================================================================
1262
+ // Working indicator — a still `●` instead of pi's 80ms spinner
1263
+ // -------------------------------------------------------------------
1264
+ // pi's braille spinner repaints the terminal ~12×/s for as long as the
1265
+ // model streams; a GPU-rendered host (Herdr) redraws its whole window
1266
+ // for every one of those frames. A single frame starts no animation
1267
+ // timer at all. pi resets the indicator on every reload / session
1268
+ // switch, so it is applied on each `session_start` (and at once when
1269
+ // the setting changes).
1270
+ // ===================================================================
1271
+
1272
+ const applyWorkingIndicator = (ctx: SessionContextLike): void => {
1273
+ try {
1274
+ if (!ctx.hasUI || typeof ctx.ui.setWorkingIndicator !== "function") return;
1275
+ if (faceliftConfig.animateWorkingIndicator) ctx.ui.setWorkingIndicator();
1276
+ else ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
1277
+ } catch {
1278
+ // Stale ctx or an older pi without the API: keep pi's default.
1279
+ }
1280
+ };
1281
+
1282
+ pi.on("session_start", (_event, ctx) => {
1283
+ applyWorkingIndicator(ctx);
1284
+ });
1285
+
1250
1286
  // ===================================================================
1251
1287
  // read — syntax-highlighted file content
1252
1288
  // ===================================================================
@@ -1855,6 +1891,15 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1855
1891
  value: faceliftConfig.showWorkingTime,
1856
1892
  default: true,
1857
1893
  },
1894
+ {
1895
+ key: "animateWorkingIndicator",
1896
+ type: "boolean",
1897
+ label: "Animated spinner",
1898
+ description:
1899
+ "Animate the spinner in front of the \u201cWorking\u2026\u201d line (pi\u2019s default, redrawn every 80ms). Off shows a still \u25cf, so the terminal isn\u2019t repainted 12\u00d7/s while the model streams \u2014 cheaper in GPU-rendered hosts such as Herdr.",
1900
+ value: faceliftConfig.animateWorkingIndicator,
1901
+ default: false,
1902
+ },
1858
1903
  ];
1859
1904
  await openSettingsModal(extCtx, {
1860
1905
  title: "@wierdbytes/pi-facelift",
@@ -1868,6 +1913,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1868
1913
  faceliftConfig = { ...faceliftConfig, showWorkingTime: value as boolean };
1869
1914
  persistFaceliftConfig(extCtx);
1870
1915
  }
1916
+ if (key === "animateWorkingIndicator") {
1917
+ faceliftConfig = { ...faceliftConfig, animateWorkingIndicator: value as boolean };
1918
+ persistFaceliftConfig(extCtx);
1919
+ applyWorkingIndicator(extCtx);
1920
+ }
1871
1921
  },
1872
1922
  });
1873
1923
  };
@@ -1877,6 +1927,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1877
1927
  `config: ${getFaceliftConfigPath()}`,
1878
1928
  `diffLayout: ${faceliftConfig.diffLayout}`,
1879
1929
  `workingTime: ${faceliftConfig.showWorkingTime ? "on" : "off"}`,
1930
+ `spinner: ${faceliftConfig.animateWorkingIndicator ? "animated" : "still"}`,
1880
1931
  ];
1881
1932
  extCtx.ui.notify(`@wierdbytes/pi-facelift\n${lines.join("\n")}`, "info");
1882
1933
  };
@@ -1884,6 +1935,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1884
1935
  const resetFaceliftConfig = (extCtx: ExtensionContext): void => {
1885
1936
  faceliftConfig = faceliftEnvDefaults();
1886
1937
  persistFaceliftConfig(extCtx);
1938
+ applyWorkingIndicator(extCtx);
1887
1939
  extCtx.ui.notify("pi-facelift: config reset to defaults.", "info");
1888
1940
  };
1889
1941
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
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": [
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for the still working indicator: on `session_start` facelift
3
+ * replaces pi's 80ms braille spinner with a single accent `●` frame (a
4
+ * single frame starts no animation timer), unless the config asks for
5
+ * the animated default (`animateWorkingIndicator: true`).
6
+ *
7
+ * The extension reads its config from `${PI_AGENT_DIR}/wierd-facelift/`,
8
+ * so each test points PI_AGENT_DIR at a temp dir with the config it needs.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import piFaceliftExtension from "./index.ts";
18
+
19
+ class MockText {
20
+ constructor(
21
+ private text = "",
22
+ _x = 0,
23
+ _y = 0,
24
+ ) {}
25
+ setText(value: string) {
26
+ this.text = value;
27
+ }
28
+ getText() {
29
+ return this.text;
30
+ }
31
+ }
32
+
33
+ let dir: string;
34
+ let savedAgentDir: string | undefined;
35
+ let savedAnimate: string | undefined;
36
+
37
+ beforeEach(() => {
38
+ dir = mkdtempSync(join(tmpdir(), "facelift-indicator-"));
39
+ savedAgentDir = process.env.PI_AGENT_DIR;
40
+ savedAnimate = process.env.FACELIFT_ANIMATE_WORKING;
41
+ process.env.PI_AGENT_DIR = dir;
42
+ delete process.env.FACELIFT_ANIMATE_WORKING;
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (savedAgentDir !== undefined) process.env.PI_AGENT_DIR = savedAgentDir;
47
+ else delete process.env.PI_AGENT_DIR;
48
+ if (savedAnimate !== undefined) process.env.FACELIFT_ANIMATE_WORKING = savedAnimate;
49
+ else delete process.env.FACELIFT_ANIMATE_WORKING;
50
+ rmSync(dir, { recursive: true, force: true });
51
+ });
52
+
53
+ function writeConfig(config: Record<string, unknown>) {
54
+ mkdirSync(join(dir, "wierd-facelift"), { recursive: true });
55
+ writeFileSync(join(dir, "wierd-facelift", "config.json"), JSON.stringify(config));
56
+ }
57
+
58
+ function loadExtension(ui: Record<string, unknown> = {}, hasUI = true) {
59
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => void>();
60
+ const pi = {
61
+ registerTool: () => {},
62
+ registerCommand: () => {},
63
+ on: (name: string, h: (event: unknown, ctx: unknown) => void) => handlers.set(name, h),
64
+ registerEntryRenderer: () => {},
65
+ appendEntry: () => {},
66
+ };
67
+ piFaceliftExtension(pi as any, {
68
+ sdk: {
69
+ createReadToolDefinition: (_cwd: string) => ({
70
+ name: "mock",
71
+ description: "mock",
72
+ parameters: { type: "object", properties: {} },
73
+ execute: async () => ({ content: [{ type: "text", text: "" }] }),
74
+ }),
75
+ getAgentDir: () => dir,
76
+ },
77
+ TextComponent: MockText as any,
78
+ });
79
+ const setWorkingIndicator = vi.fn();
80
+ const ctx = {
81
+ hasUI,
82
+ ui: {
83
+ setWorkingMessage: vi.fn(),
84
+ setWorkingIndicator,
85
+ theme: { fg: (token: string, text: string) => `<${token}>${text}</${token}>` },
86
+ ...ui,
87
+ },
88
+ };
89
+ const fire = (name: string) => handlers.get(name)?.({}, ctx);
90
+ return { fire, setWorkingIndicator, ctx };
91
+ }
92
+
93
+ describe("working indicator", () => {
94
+ it("sets a single still accent frame on session_start by default", () => {
95
+ const { fire, setWorkingIndicator } = loadExtension();
96
+ fire("session_start");
97
+ expect(setWorkingIndicator).toHaveBeenCalledTimes(1);
98
+ expect(setWorkingIndicator).toHaveBeenCalledWith({ frames: ["<accent>●</accent>"] });
99
+ });
100
+
101
+ it("keeps an older config file (no field) on the still indicator", () => {
102
+ writeConfig({ diffLayout: "split" });
103
+ const { fire, setWorkingIndicator } = loadExtension();
104
+ fire("session_start");
105
+ expect(setWorkingIndicator).toHaveBeenCalledWith({ frames: ["<accent>●</accent>"] });
106
+ });
107
+
108
+ it("restores pi's animated default when animateWorkingIndicator is on", () => {
109
+ writeConfig({ diffLayout: "split", animateWorkingIndicator: true });
110
+ const { fire, setWorkingIndicator } = loadExtension();
111
+ fire("session_start");
112
+ expect(setWorkingIndicator).toHaveBeenCalledTimes(1);
113
+ expect(setWorkingIndicator).toHaveBeenCalledWith();
114
+ });
115
+
116
+ it("does nothing without a UI (pi -p, RPC)", () => {
117
+ const { fire, setWorkingIndicator } = loadExtension({}, false);
118
+ fire("session_start");
119
+ expect(setWorkingIndicator).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it("tolerates a pi without setWorkingIndicator", () => {
123
+ const { fire } = loadExtension({ setWorkingIndicator: undefined });
124
+ expect(() => fire("session_start")).not.toThrow();
125
+ });
126
+
127
+ it("swallows a stale ctx instead of crashing pi", () => {
128
+ const { fire } = loadExtension({
129
+ setWorkingIndicator: () => {
130
+ throw new Error("extension ctx is stale");
131
+ },
132
+ });
133
+ expect(() => fire("session_start")).not.toThrow();
134
+ });
135
+ });
@@ -4,9 +4,13 @@
4
4
  * The tracker measures two metrics: `worked` (model streaming only,
5
5
  * tools excluded) and `totalMs` (wall-clock from user prompt to settle,
6
6
  * retries/tools/overhead included). The wiring drives it from pi
7
- * lifecycle events: before_agent_start / agent_start /
7
+ * lifecycle events: before_agent_start / agent_start / turn_start /
8
8
  * before_provider_request / message_end / agent_settled. The durable
9
9
  * history line is produced via pi.appendEntry + registerEntryRenderer.
10
+ *
11
+ * `before_provider_request` also fires for pi's prompt-cache warm-ups,
12
+ * which never produce a `message_end`; only requests armed by `turn_start`
13
+ * may open a segment.
10
14
  */
11
15
 
12
16
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -27,6 +31,7 @@ describe("WorkingTimeTracker", () => {
27
31
  const t = new WorkingTimeTracker(() => now);
28
32
  t.beginRun();
29
33
  now = 2000;
34
+ t.armRequest();
30
35
  t.beginSegment();
31
36
  now = 4500;
32
37
  t.endSegment();
@@ -40,11 +45,13 @@ describe("WorkingTimeTracker", () => {
40
45
  let now = 0;
41
46
  const t = new WorkingTimeTracker(() => now);
42
47
  t.beginRun();
48
+ t.armRequest();
43
49
  t.beginSegment();
44
50
  now = 1000;
45
51
  t.endSegment();
46
52
  // tool + overhead from 1000..9000 counts toward total, not worked
47
53
  now = 9000;
54
+ t.armRequest();
48
55
  t.beginSegment();
49
56
  now = 10000;
50
57
  t.endSegment();
@@ -57,6 +64,7 @@ describe("WorkingTimeTracker", () => {
57
64
  let now = 0;
58
65
  const t = new WorkingTimeTracker(() => now);
59
66
  t.beginRun();
67
+ t.armRequest();
60
68
  t.beginSegment();
61
69
  now = 500;
62
70
  t.endSegment();
@@ -71,6 +79,7 @@ describe("WorkingTimeTracker", () => {
71
79
  let now = 0;
72
80
  const t = new WorkingTimeTracker(() => now);
73
81
  t.beginRun();
82
+ t.armRequest();
74
83
  t.beginSegment();
75
84
  t.addDelta(4000); // ~1000 estimated tokens at 4 chars/token
76
85
  now = 2000; // 2s of worked time
@@ -81,6 +90,7 @@ describe("WorkingTimeTracker", () => {
81
90
  it("liveTps is 0 before any time elapses", () => {
82
91
  const t = new WorkingTimeTracker(() => 0);
83
92
  t.beginRun();
93
+ t.armRequest();
84
94
  t.beginSegment();
85
95
  t.addDelta(400);
86
96
  expect(t.liveTps()).toBe(0);
@@ -90,6 +100,7 @@ describe("WorkingTimeTracker", () => {
90
100
  let now = 0;
91
101
  const t = new WorkingTimeTracker(() => now);
92
102
  t.beginRun();
103
+ t.armRequest();
93
104
  t.beginSegment();
94
105
  now = 2000;
95
106
  t.recordExactTokens(1500);
@@ -104,12 +115,14 @@ describe("WorkingTimeTracker", () => {
104
115
  const t = new WorkingTimeTracker(() => now);
105
116
  t.beginRun();
106
117
  // first model segment: 0..1000
118
+ t.armRequest();
107
119
  t.beginSegment();
108
120
  now = 1000;
109
121
  t.endSegment();
110
122
  // "tool" runs from 1000..9000 — must NOT count
111
123
  now = 9000;
112
124
  // second model segment: 9000..10000
125
+ t.armRequest();
113
126
  t.beginSegment();
114
127
  now = 10000;
115
128
  t.endSegment();
@@ -120,6 +133,7 @@ describe("WorkingTimeTracker", () => {
120
133
  let now = 0;
121
134
  const t = new WorkingTimeTracker(() => now);
122
135
  t.beginRun();
136
+ t.armRequest();
123
137
  t.beginSegment();
124
138
  now = 3000;
125
139
  expect(t.elapsedMs()).toBe(3000);
@@ -132,10 +146,63 @@ describe("WorkingTimeTracker", () => {
132
146
  expect(t.settle()).toBeUndefined();
133
147
  });
134
148
 
149
+ it("ignores an unarmed request (cache warm-up while idle)", () => {
150
+ let now = 0;
151
+ const t = new WorkingTimeTracker(() => now);
152
+ expect(t.beginSegment()).toBe(false);
153
+ now = 5000;
154
+ expect(t.isActive()).toBe(false);
155
+ expect(t.elapsedMs()).toBe(0);
156
+ });
157
+
158
+ it("ignores a cache warm-up during tool execution", () => {
159
+ let now = 0;
160
+ const t = new WorkingTimeTracker(() => now);
161
+ t.beginRun();
162
+ t.armRequest();
163
+ expect(t.beginSegment()).toBe(true);
164
+ now = 1000;
165
+ t.endSegment();
166
+ // tool runs 1000..9000; the warmer replays the request at 4000
167
+ now = 4000;
168
+ expect(t.beginSegment()).toBe(false);
169
+ now = 9000;
170
+ t.armRequest();
171
+ expect(t.beginSegment()).toBe(true);
172
+ now = 10000;
173
+ t.endSegment();
174
+ expect(t.settle()?.ms).toBe(2000);
175
+ });
176
+
177
+ it("keeps the first start when the request hook fires again mid-segment", () => {
178
+ let now = 0;
179
+ const t = new WorkingTimeTracker(() => now);
180
+ t.beginRun();
181
+ t.armRequest();
182
+ expect(t.beginSegment()).toBe(true);
183
+ now = 1000;
184
+ expect(t.beginSegment()).toBe(false); // e.g. a provider re-sending the payload
185
+ now = 3000;
186
+ t.endSegment();
187
+ expect(t.settle()?.ms).toBe(3000);
188
+ });
189
+
190
+ it("disarms on settle and on beginRun", () => {
191
+ const t = new WorkingTimeTracker(() => 0);
192
+ t.beginRun();
193
+ t.armRequest();
194
+ t.settle();
195
+ expect(t.beginSegment()).toBe(false);
196
+ t.armRequest();
197
+ t.beginRun();
198
+ expect(t.beginSegment()).toBe(false);
199
+ });
200
+
135
201
  it("resets state after settle", () => {
136
202
  let now = 0;
137
203
  const t = new WorkingTimeTracker(() => now);
138
204
  t.beginRun();
205
+ t.armRequest();
139
206
  t.beginSegment();
140
207
  now = 1000;
141
208
  t.endSegment();
@@ -264,11 +331,13 @@ describe("working-time wiring", () => {
264
331
  fire("before_agent_start", {}); // anchors the total clock
265
332
  vi.advanceTimersByTime(500); // pre-loop overhead
266
333
  fire("agent_start", {});
334
+ fire("turn_start", {});
267
335
  fire("before_provider_request", {});
268
336
  vi.advanceTimersByTime(5000);
269
337
  fire("message_end", asst);
270
338
  // tool gap — counts toward total, NOT worked
271
339
  vi.advanceTimersByTime(3000);
340
+ fire("turn_start", {});
272
341
  fire("before_provider_request", {});
273
342
  vi.advanceTimersByTime(2000);
274
343
  fire("message_end", asst);
@@ -287,6 +356,7 @@ describe("working-time wiring", () => {
287
356
  const { fire, entries, ctx } = loadExtension();
288
357
  fire("before_agent_start", {});
289
358
  fire("agent_start", {});
359
+ fire("turn_start", {});
290
360
  fire("before_provider_request", {});
291
361
  // Stream ~4000 chars => ~1000 estimated tokens over 2s of worked time.
292
362
  fire("message_update", {
@@ -311,6 +381,7 @@ describe("working-time wiring", () => {
311
381
  const { fire, entries } = loadExtension();
312
382
  fire("before_agent_start", {});
313
383
  fire("agent_start", {});
384
+ fire("turn_start", {});
314
385
  fire("before_provider_request", {});
315
386
  vi.advanceTimersByTime(1000);
316
387
  fire("message_end", user); // stray non-assistant end — ignored
@@ -327,6 +398,7 @@ describe("working-time wiring", () => {
327
398
  const { fire, ctx } = loadExtension();
328
399
  fire("before_agent_start", {});
329
400
  fire("agent_start", {});
401
+ fire("turn_start", {});
330
402
  fire("before_provider_request", {});
331
403
  vi.advanceTimersByTime(2000);
332
404
  const calls = ctx.ui.setWorkingMessage.mock.calls.map((c) => c[0]);
@@ -340,6 +412,64 @@ describe("working-time wiring", () => {
340
412
  expect(last?.[0]).toBeUndefined();
341
413
  });
342
414
 
415
+ it("ignores a cache warm-up during tool execution (no segment, no ticker)", () => {
416
+ vi.useFakeTimers();
417
+ const { fire, entries, ctx } = loadExtension();
418
+ fire("before_agent_start", {});
419
+ fire("agent_start", {});
420
+ fire("turn_start", {});
421
+ fire("before_provider_request", {});
422
+ vi.advanceTimersByTime(1000);
423
+ fire("message_end", asst);
424
+ // tool executes; the cache warmer replays the request mid-tool
425
+ vi.advanceTimersByTime(2000);
426
+ const callsBefore = ctx.ui.setWorkingMessage.mock.calls.length;
427
+ fire("before_provider_request", {});
428
+ vi.advanceTimersByTime(5000);
429
+ expect(ctx.ui.setWorkingMessage.mock.calls.length).toBe(callsBefore);
430
+ fire("turn_start", {});
431
+ fire("before_provider_request", {});
432
+ vi.advanceTimersByTime(1000);
433
+ fire("message_end", asst);
434
+ fire("agent_settled", {});
435
+ vi.useRealTimers();
436
+ expect(entries).toHaveLength(1);
437
+ const data = entries[0].data as WorkingTimeEntry;
438
+ expect(data.ms).toBe(2000); // the 7s tool gap stays excluded
439
+ expect(data.totalMs).toBe(9000);
440
+ });
441
+
442
+ it("ignores a cache warm-up after settle (idle warming): no phantom run or ticker", () => {
443
+ vi.useFakeTimers();
444
+ const { fire, entries, ctx } = loadExtension();
445
+ fire("before_agent_start", {});
446
+ fire("agent_start", {});
447
+ fire("turn_start", {});
448
+ fire("before_provider_request", {});
449
+ vi.advanceTimersByTime(1000);
450
+ fire("message_end", asst);
451
+ fire("agent_settled", {});
452
+ ctx.ui.setWorkingMessage.mockClear();
453
+ // idle warm-up minutes later
454
+ vi.advanceTimersByTime(270_000);
455
+ fire("before_provider_request", {});
456
+ vi.advanceTimersByTime(5000);
457
+ expect(ctx.ui.setWorkingMessage).not.toHaveBeenCalled();
458
+ // the next real run is measured from its own prompt
459
+ fire("before_agent_start", {});
460
+ fire("agent_start", {});
461
+ fire("turn_start", {});
462
+ fire("before_provider_request", {});
463
+ vi.advanceTimersByTime(1500);
464
+ fire("message_end", asst);
465
+ fire("agent_settled", {});
466
+ vi.useRealTimers();
467
+ expect(entries).toHaveLength(2);
468
+ const data = entries[1].data as WorkingTimeEntry;
469
+ expect(data.ms).toBe(1500);
470
+ expect(data.totalMs).toBe(1500);
471
+ });
472
+
343
473
  it("registers an entry renderer that produces a dim worked+total line", () => {
344
474
  const { entryRenderers } = loadExtension();
345
475
  const renderer = entryRenderers.get(WORKING_TIME_ENTRY);
package/working-time.ts CHANGED
@@ -61,6 +61,7 @@ export interface WorkingTimeEntry {
61
61
  * Lifecycle (driven by pi events):
62
62
  * before_agent_start → beginRun() (total clock starts here)
63
63
  * agent_start → ensureRun() (fallback if before_agent_start skipped)
64
+ * turn_start → armRequest() (an agent request is coming)
64
65
  * before_provider_request → beginSegment() + start the live ticker
65
66
  * message_update (asst) → addDelta() (feed the live tps estimate)
66
67
  * message_end (asst) → recordExactTokens() + endSegment() + stop ticker
@@ -68,6 +69,15 @@ export interface WorkingTimeEntry {
68
69
  *
69
70
  * Retries / auto-compaction fire their own agent_start but pi only emits
70
71
  * `agent_settled` when it truly stops, so a run is measured end-to-end.
72
+ *
73
+ * `before_provider_request` is not exclusive to agent turns: pi's prompt
74
+ * cache warmer replays the last request (`maxTokens: 1`) with the same
75
+ * options, so the hook fires for warm-ups too — mid-run while tools execute,
76
+ * and after the run settles in `idle` warming mode. Those requests never
77
+ * produce a `message_end`, so an unguarded segment would stay open and
78
+ * swallow tool time (or start a phantom run while idle). Segments therefore
79
+ * open only while armed: from `turn_start` (pi emits one before every agent
80
+ * request) until the assistant `message_end`.
71
81
  */
72
82
  export class WorkingTimeTracker {
73
83
  private readonly now: () => number;
@@ -76,6 +86,7 @@ export class WorkingTimeTracker {
76
86
  private runStart: number | undefined;
77
87
  private runEstChars = 0;
78
88
  private runExactTokens = 0;
89
+ private requestArmed = false;
79
90
 
80
91
  constructor(now: () => number = () => Date.now()) {
81
92
  this.now = now;
@@ -88,6 +99,7 @@ export class WorkingTimeTracker {
88
99
  this.runStart = this.now();
89
100
  this.runEstChars = 0;
90
101
  this.runExactTokens = 0;
102
+ this.requestArmed = false;
91
103
  }
92
104
 
93
105
  /** Start a run only if one isn't already in progress (idempotent). */
@@ -95,14 +107,29 @@ export class WorkingTimeTracker {
95
107
  if (this.runStart === undefined) this.beginRun();
96
108
  }
97
109
 
98
- /** Begin timing an assistant streaming segment. */
99
- beginSegment(): void {
110
+ /**
111
+ * Mark that the agent is about to issue a real request (pi `turn_start`).
112
+ * Only an armed tracker lets `beginSegment` open a segment.
113
+ */
114
+ armRequest(): void {
100
115
  this.ensureRun();
101
- if (this.segmentStart === undefined) this.segmentStart = this.now();
116
+ this.requestArmed = true;
117
+ }
118
+
119
+ /**
120
+ * Begin timing an assistant streaming segment. Ignored unless armed (see
121
+ * `armRequest`) or when a segment is already open. Returns `true` only
122
+ * when a new segment was opened.
123
+ */
124
+ beginSegment(): boolean {
125
+ if (!this.requestArmed || this.segmentStart !== undefined) return false;
126
+ this.segmentStart = this.now();
127
+ return true;
102
128
  }
103
129
 
104
- /** Close the current segment, folding its duration into the total. */
130
+ /** Close the current segment, folding its duration into the total, and disarm. */
105
131
  endSegment(): void {
132
+ this.requestArmed = false;
106
133
  if (this.segmentStart !== undefined) {
107
134
  this.accumulatorMs += Math.max(0, this.now() - this.segmentStart);
108
135
  this.segmentStart = undefined;