@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 +8 -0
- package/README.md +13 -2
- package/config.test.ts +50 -17
- package/config.ts +17 -1
- package/index.ts +54 -2
- package/package.json +1 -1
- package/working-indicator.test.ts +135 -0
- package/working-time.test.ts +131 -1
- package/working-time.ts +31 -4
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|
package/working-time.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
99
|
-
|
|
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
|
-
|
|
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;
|