@wierdbytes/pi-facelift 0.6.6 → 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,9 @@
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
+
3
7
  ## 0.6.6
4
8
 
5
9
  - **Fix:** prompt-cache warm-ups no longer inflate `worked` time / deflate `tps` or start a phantom working timer while idle.
package/README.md CHANGED
@@ -62,6 +62,11 @@ tool output.
62
62
  (`0h12m0s` → `12m0s`); sub-minute values keep one decimal (`45.2s`).
63
63
 
64
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`.
65
70
 
66
71
  ## Install
67
72
 
@@ -128,7 +133,11 @@ Current schema:
128
133
  // each run's model time (`worked`, tools excluded) and wall-clock time
129
134
  // (`total`, retries + tools + overhead) as a muted line in chat
130
135
  // history. Default true.
131
- "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
132
141
  }
133
142
  ```
134
143
 
@@ -147,6 +156,7 @@ icons, image protocols) or boot-time toggles:
147
156
  | `FACELIFT_IMAGE_PROTOCOL` | auto | Force `kitty` / `iterm2` / `none`. Auto-detected from `$TERM_PROGRAM` etc. |
148
157
  | `DIFF_LAYOUT` | (none) | One-shot override for `diffLayout` on first-run seeding. |
149
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). |
150
160
 
151
161
  ## Development
152
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
@@ -1258,6 +1258,31 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1258
1258
  if (entry) pi.appendEntry(WORKING_TIME_ENTRY, entry);
1259
1259
  });
1260
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
+
1261
1286
  // ===================================================================
1262
1287
  // read — syntax-highlighted file content
1263
1288
  // ===================================================================
@@ -1866,6 +1891,15 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1866
1891
  value: faceliftConfig.showWorkingTime,
1867
1892
  default: true,
1868
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
+ },
1869
1903
  ];
1870
1904
  await openSettingsModal(extCtx, {
1871
1905
  title: "@wierdbytes/pi-facelift",
@@ -1879,6 +1913,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1879
1913
  faceliftConfig = { ...faceliftConfig, showWorkingTime: value as boolean };
1880
1914
  persistFaceliftConfig(extCtx);
1881
1915
  }
1916
+ if (key === "animateWorkingIndicator") {
1917
+ faceliftConfig = { ...faceliftConfig, animateWorkingIndicator: value as boolean };
1918
+ persistFaceliftConfig(extCtx);
1919
+ applyWorkingIndicator(extCtx);
1920
+ }
1882
1921
  },
1883
1922
  });
1884
1923
  };
@@ -1888,6 +1927,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1888
1927
  `config: ${getFaceliftConfigPath()}`,
1889
1928
  `diffLayout: ${faceliftConfig.diffLayout}`,
1890
1929
  `workingTime: ${faceliftConfig.showWorkingTime ? "on" : "off"}`,
1930
+ `spinner: ${faceliftConfig.animateWorkingIndicator ? "animated" : "still"}`,
1891
1931
  ];
1892
1932
  extCtx.ui.notify(`@wierdbytes/pi-facelift\n${lines.join("\n")}`, "info");
1893
1933
  };
@@ -1895,6 +1935,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1895
1935
  const resetFaceliftConfig = (extCtx: ExtensionContext): void => {
1896
1936
  faceliftConfig = faceliftEnvDefaults();
1897
1937
  persistFaceliftConfig(extCtx);
1938
+ applyWorkingIndicator(extCtx);
1898
1939
  extCtx.ui.notify("pi-facelift: config reset to defaults.", "info");
1899
1940
  };
1900
1941
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.6.6",
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
+ });