@wierdbytes/pi-facelift 0.4.4 → 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,17 @@
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
+
7
+ ## 0.5.0
8
+
9
+ - **Fix:** facelift no longer silently disables itself on pi 0.79.x — tool
10
+ output renders with facelift frames again instead of pi's default renderer.
11
+ The SDK and pi-tui are now loaded via static `import` rather than a runtime
12
+ `require()`, which jiti's package aliases broke for the new
13
+ `@earendil-works/pi-ai/base` subpath.
14
+
3
15
  ## 0.4.0
4
16
 
5
17
  - **New**: GitHub-style split/unified diff renderer for the `write`
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 # 11 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
@@ -44,7 +44,19 @@ import type {
44
44
  WriteToolInput,
45
45
  } from "@earendil-works/pi-coding-agent";
46
46
  import type { Theme } from "@earendil-works/pi-coding-agent";
47
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
47
+ // Runtime SDK values are imported *statically* (not via `require()`).
48
+ //
49
+ // pi loads extensions through jiti, which aliases bare package specifiers
50
+ // (e.g. `@earendil-works/pi-ai`) to a single `dist/index.js` file. A runtime
51
+ // `require("@earendil-works/pi-coding-agent")` makes jiti resolve the SDK's
52
+ // transitive subpath imports (notably `@earendil-works/pi-ai/base`, pulled in
53
+ // by `@earendil-works/pi-agent-core` since pi 0.79.x) via prefix string
54
+ // replacement — yielding `.../pi-ai/dist/index.js/base`, which does not exist.
55
+ // The throw used to be swallowed by a `try/catch { return }`, silently
56
+ // disabling the whole extension (tools fell back to pi's default rendering).
57
+ // Static `import` resolves subpath exports correctly, so we use it instead.
58
+ import * as piCodingAgentSdk from "@earendil-works/pi-coding-agent";
59
+ import { Text as PiText, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
48
60
  import { codeToANSI } from "@shikijs/cli";
49
61
  import { bundledThemes } from "shiki";
50
62
  import {
@@ -71,6 +83,13 @@ import {
71
83
  type DiffLayoutPreference,
72
84
  type WierdFaceliftConfig,
73
85
  } from "./config.ts";
86
+ import {
87
+ WorkingTimeTracker,
88
+ WORKING_TIME_ENTRY,
89
+ workingMessageText,
90
+ workingTimeLine,
91
+ type WorkingTimeEntry,
92
+ } from "./working-time.ts";
74
93
  import {
75
94
  formatDuration,
76
95
  frameBodyLines,
@@ -979,6 +998,11 @@ type PiFaceliftApi = {
979
998
  },
980
999
  ) => void;
981
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;
982
1006
  };
983
1007
  type ReadParams = ReadToolInput;
984
1008
  type BashParams = BashToolInput;
@@ -1047,7 +1071,8 @@ function setResultDetails<T>(result: ToolResultLike, details: T): void {
1047
1071
 
1048
1072
  /**
1049
1073
  * Dependencies that can be injected for testing.
1050
- * In production, omit `deps` — the extension uses require() to load them.
1074
+ * In production, omit `deps` — the extension uses the statically-imported
1075
+ * SDK (`@earendil-works/pi-coding-agent`) and pi-tui `Text` component.
1051
1076
  */
1052
1077
  export interface PiFaceliftDeps {
1053
1078
  sdk: PiFaceliftSdk;
@@ -1078,19 +1103,16 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1078
1103
  createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
1079
1104
  TextComponent = deps.TextComponent;
1080
1105
  } else {
1081
- try {
1082
- sdk = require("@earendil-works/pi-coding-agent");
1083
- createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
1084
- createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
1085
- createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
1086
- createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
1087
- createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
1088
- createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
1089
- createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
1090
- TextComponent = require("@earendil-works/pi-tui").Text;
1091
- } catch {
1092
- return;
1093
- }
1106
+ // Production path: use the statically-imported SDK + pi-tui Text.
1107
+ sdk = piCodingAgentSdk as unknown as PiFaceliftSdk;
1108
+ createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
1109
+ createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
1110
+ createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
1111
+ createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
1112
+ createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
1113
+ createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
1114
+ createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
1115
+ TextComponent = PiText as unknown as TextComponentCtor;
1094
1116
  }
1095
1117
  if (!createReadTool || !TextComponent) return;
1096
1118
 
@@ -1126,6 +1148,78 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1126
1148
  }
1127
1149
  })();
1128
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
+
1129
1223
  // ===================================================================
1130
1224
  // read — syntax-highlighted file content
1131
1225
  // ===================================================================
@@ -1725,6 +1819,15 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1725
1819
  options: VALID_DIFF_LAYOUTS,
1726
1820
  optionLabels: diffLayoutLabels,
1727
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
+ },
1728
1831
  ];
1729
1832
  await openSettingsModal(extCtx, {
1730
1833
  title: "@wierdbytes/pi-facelift",
@@ -1734,6 +1837,10 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1734
1837
  faceliftConfig = { ...faceliftConfig, diffLayout: value as DiffLayoutPreference };
1735
1838
  persistFaceliftConfig(extCtx);
1736
1839
  }
1840
+ if (key === "showWorkingTime") {
1841
+ faceliftConfig = { ...faceliftConfig, showWorkingTime: value as boolean };
1842
+ persistFaceliftConfig(extCtx);
1843
+ }
1737
1844
  },
1738
1845
  });
1739
1846
  };
@@ -1742,6 +1849,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1742
1849
  const lines = [
1743
1850
  `config: ${getFaceliftConfigPath()}`,
1744
1851
  `diffLayout: ${faceliftConfig.diffLayout}`,
1852
+ `workingTime: ${faceliftConfig.showWorkingTime ? "on" : "off"}`,
1745
1853
  ];
1746
1854
  extCtx.ui.notify(`@wierdbytes/pi-facelift\n${lines.join("\n")}`, "info");
1747
1855
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.4.4",
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, {