@wierdbytes/pi-facelift 0.5.0 → 0.6.2
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 +25 -2
- package/bash-rendering.test.ts +2 -0
- package/config.test.ts +47 -14
- package/config.ts +27 -3
- package/edit-line-numbers.test.ts +2 -0
- package/image-rendering.test.ts +2 -0
- package/index.ts +98 -0
- package/package.json +1 -1
- package/read-rendering.test.ts +2 -0
- package/working-time.test.ts +292 -0
- package/working-time.ts +163 -0
- package/write-edit-concurrency.test.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.2
|
|
4
|
+
|
|
5
|
+
- **Fix:** live `Working...` timer now uses the same duration format as the history line (`1m5s` instead of `65s`).
|
|
6
|
+
|
|
7
|
+
## 0.6.1
|
|
8
|
+
|
|
9
|
+
- **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`.
|
|
10
|
+
|
|
3
11
|
## 0.5.0
|
|
4
12
|
|
|
5
13
|
- **Fix:** facelift no longer silently disables itself on pi 0.79.x — tool
|
package/README.md
CHANGED
|
@@ -40,6 +40,22 @@ tool output.
|
|
|
40
40
|
don't happen — configurable via `diffLayout` (see below).
|
|
41
41
|
|
|
42
42
|
[cd]: ../common/README.md#diff
|
|
43
|
+
- **working timer** — while the model streams, the `Working...` spinner
|
|
44
|
+
line gains a live ticking timer (`Working... 12s`). When the run
|
|
45
|
+
settles, a muted line is left behind in chat history (a durable custom
|
|
46
|
+
entry, not sent to the LLM) reporting two numbers:
|
|
47
|
+
|
|
48
|
+
`⏱ worked 45.2s (total: 1m15s)`
|
|
49
|
+
|
|
50
|
+
- `worked` — model-only time (request dispatch → response end, so
|
|
51
|
+
time-to-first-token counts; tool execution is excluded).
|
|
52
|
+
- `total` — wall-clock time from your prompt to the final response,
|
|
53
|
+
including retries, tool calls, and all other overhead.
|
|
54
|
+
|
|
55
|
+
Durations auto-format as `1h12m36s` — leading zero parts are dropped
|
|
56
|
+
(`0h12m0s` → `12m0s`); sub-minute values keep one decimal (`45.2s`).
|
|
57
|
+
|
|
58
|
+
Toggle via `showWorkingTime` in `/facelift`.
|
|
43
59
|
|
|
44
60
|
## Install
|
|
45
61
|
|
|
@@ -100,7 +116,13 @@ Current schema:
|
|
|
100
116
|
// "unified" : always stacked single-column.
|
|
101
117
|
// "per-edit" : each diff picks independently (original pi-diff
|
|
102
118
|
// behaviour; can produce mixed layouts).
|
|
103
|
-
"diffLayout": "consistent"
|
|
119
|
+
"diffLayout": "consistent",
|
|
120
|
+
|
|
121
|
+
// Show a ticking timer in the streaming "Working..." line and persist
|
|
122
|
+
// each run's model time (`worked`, tools excluded) and wall-clock time
|
|
123
|
+
// (`total`, retries + tools + overhead) as a muted line in chat
|
|
124
|
+
// history. Default true.
|
|
125
|
+
"showWorkingTime": true
|
|
104
126
|
}
|
|
105
127
|
```
|
|
106
128
|
|
|
@@ -118,6 +140,7 @@ icons, image protocols) or boot-time toggles:
|
|
|
118
140
|
| `FACELIFT_ICONS` | `nerd` | Set to `none` / `off` to disable Nerd Font icons in `ls`/`find`/`grep`. |
|
|
119
141
|
| `FACELIFT_IMAGE_PROTOCOL` | auto | Force `kitty` / `iterm2` / `none`. Auto-detected from `$TERM_PROGRAM` etc. |
|
|
120
142
|
| `DIFF_LAYOUT` | (none) | One-shot override for `diffLayout` on first-run seeding. |
|
|
143
|
+
| `FACELIFT_SHOW_WORKING_TIME` | `true` | One-shot override for `showWorkingTime` on first-run seeding (`0`/`off` ⇒ off). |
|
|
121
144
|
|
|
122
145
|
## Development
|
|
123
146
|
|
|
@@ -126,7 +149,7 @@ This package lives in the [`pi-wierd-stuff`](../../README.md) monorepo.
|
|
|
126
149
|
```bash
|
|
127
150
|
bun install # from the repo root
|
|
128
151
|
cd packages/facelift
|
|
129
|
-
bun run test #
|
|
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
|
```
|
package/bash-rendering.test.ts
CHANGED
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
|
|
14
|
-
* designed to accept more fields (icon mode, max preview
|
|
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;
|
package/image-rendering.test.ts
CHANGED
package/index.ts
CHANGED
|
@@ -83,6 +83,13 @@ import {
|
|
|
83
83
|
type DiffLayoutPreference,
|
|
84
84
|
type WierdFaceliftConfig,
|
|
85
85
|
} from "./config.ts";
|
|
86
|
+
import {
|
|
87
|
+
WorkingTimeTracker,
|
|
88
|
+
WORKING_TIME_ENTRY,
|
|
89
|
+
workingMessageText,
|
|
90
|
+
workingTimeLine,
|
|
91
|
+
type WorkingTimeEntry,
|
|
92
|
+
} from "./working-time.ts";
|
|
86
93
|
import {
|
|
87
94
|
formatDuration,
|
|
88
95
|
frameBodyLines,
|
|
@@ -991,6 +998,11 @@ type PiFaceliftApi = {
|
|
|
991
998
|
},
|
|
992
999
|
) => void;
|
|
993
1000
|
on: (event: string, handler: (event: unknown, ctx: SessionContextLike) => Promise<void> | void) => void;
|
|
1001
|
+
registerEntryRenderer: <T = unknown>(
|
|
1002
|
+
customType: string,
|
|
1003
|
+
renderer: (entry: { data?: T }, options: { expanded: boolean }, theme: ThemeLike) => unknown,
|
|
1004
|
+
) => void;
|
|
1005
|
+
appendEntry: <T = unknown>(customType: string, data?: T) => void;
|
|
994
1006
|
};
|
|
995
1007
|
type ReadParams = ReadToolInput;
|
|
996
1008
|
type BashParams = BashToolInput;
|
|
@@ -1136,6 +1148,78 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1136
1148
|
}
|
|
1137
1149
|
})();
|
|
1138
1150
|
|
|
1151
|
+
// ===================================================================
|
|
1152
|
+
// Working-time — live timer in the "Working…" line + durable history
|
|
1153
|
+
// -------------------------------------------------------------------
|
|
1154
|
+
// Two metrics per run: `worked` (model streaming only — request
|
|
1155
|
+
// dispatch → assistant message_end, so tools between turns are
|
|
1156
|
+
// excluded, time-to-first-token included) and `total` (wall-clock
|
|
1157
|
+
// from user prompt to settle — retries, tools, overhead included).
|
|
1158
|
+
// While streaming, a 1s ticker rewrites the working *message* (spinner
|
|
1159
|
+
// frames are left alone). On `agent_settled` both are persisted as a
|
|
1160
|
+
// durable custom entry rendered as a plain muted line in history.
|
|
1161
|
+
// ===================================================================
|
|
1162
|
+
|
|
1163
|
+
pi.registerEntryRenderer<WorkingTimeEntry>(WORKING_TIME_ENTRY, (entry, _opts, theme) => {
|
|
1164
|
+
const data = entry.data;
|
|
1165
|
+
if (!data || typeof data.ms !== "number") return undefined;
|
|
1166
|
+
return new TextComponent(workingTimeLine(data, (s) => theme.fg("dim", s)), 0, 0);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
const workingTracker = new WorkingTimeTracker();
|
|
1170
|
+
let workingTick: NodeJS.Timeout | undefined;
|
|
1171
|
+
|
|
1172
|
+
const stopWorkingTick = (): void => {
|
|
1173
|
+
if (workingTick !== undefined) {
|
|
1174
|
+
clearInterval(workingTick);
|
|
1175
|
+
workingTick = undefined;
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
const renderWorkingMessage = (ctx: SessionContextLike): void => {
|
|
1180
|
+
if (ctx.hasUI) ctx.ui.setWorkingMessage(workingMessageText(workingTracker.elapsedMs()));
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
// `before_agent_start` fires right after the user submits the prompt,
|
|
1184
|
+
// so it anchors the wall-clock `total` (retries + tools + overhead).
|
|
1185
|
+
pi.on("before_agent_start", () => {
|
|
1186
|
+
if (!faceliftConfig.showWorkingTime) return;
|
|
1187
|
+
workingTracker.beginRun();
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
pi.on("agent_start", () => {
|
|
1191
|
+
if (!faceliftConfig.showWorkingTime) return;
|
|
1192
|
+
// Fallback for flows where before_agent_start didn't fire first.
|
|
1193
|
+
workingTracker.ensureRun();
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
pi.on("before_provider_request", (_event, ctx) => {
|
|
1197
|
+
if (!faceliftConfig.showWorkingTime) return;
|
|
1198
|
+
// Start the segment when the request is dispatched (not on the first
|
|
1199
|
+
// token), so time-to-first-token counts as working time too.
|
|
1200
|
+
workingTracker.beginSegment();
|
|
1201
|
+
stopWorkingTick();
|
|
1202
|
+
renderWorkingMessage(ctx);
|
|
1203
|
+
workingTick = setInterval(() => renderWorkingMessage(ctx), 1000);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
pi.on("message_end", (event, _ctx) => {
|
|
1207
|
+
if (!faceliftConfig.showWorkingTime) return;
|
|
1208
|
+
const role = (event as { message?: { role?: string } }).message?.role;
|
|
1209
|
+
if (role !== "assistant") return;
|
|
1210
|
+
workingTracker.endSegment();
|
|
1211
|
+
stopWorkingTick();
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
1215
|
+
if (!faceliftConfig.showWorkingTime) return;
|
|
1216
|
+
stopWorkingTick();
|
|
1217
|
+
const entry = workingTracker.settle();
|
|
1218
|
+
// Restore pi's default working message for the next run.
|
|
1219
|
+
if (ctx.hasUI) ctx.ui.setWorkingMessage();
|
|
1220
|
+
if (entry) pi.appendEntry(WORKING_TIME_ENTRY, entry);
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1139
1223
|
// ===================================================================
|
|
1140
1224
|
// read — syntax-highlighted file content
|
|
1141
1225
|
// ===================================================================
|
|
@@ -1735,6 +1819,15 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1735
1819
|
options: VALID_DIFF_LAYOUTS,
|
|
1736
1820
|
optionLabels: diffLayoutLabels,
|
|
1737
1821
|
},
|
|
1822
|
+
{
|
|
1823
|
+
key: "showWorkingTime",
|
|
1824
|
+
type: "boolean",
|
|
1825
|
+
label: "Working timer",
|
|
1826
|
+
description:
|
|
1827
|
+
"Show a ticking timer in the streaming \u201cWorking\u2026\u201d line and persist each agent run\u2019s total model time (tools excluded) as a muted line in chat history.",
|
|
1828
|
+
value: faceliftConfig.showWorkingTime,
|
|
1829
|
+
default: true,
|
|
1830
|
+
},
|
|
1738
1831
|
];
|
|
1739
1832
|
await openSettingsModal(extCtx, {
|
|
1740
1833
|
title: "@wierdbytes/pi-facelift",
|
|
@@ -1744,6 +1837,10 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1744
1837
|
faceliftConfig = { ...faceliftConfig, diffLayout: value as DiffLayoutPreference };
|
|
1745
1838
|
persistFaceliftConfig(extCtx);
|
|
1746
1839
|
}
|
|
1840
|
+
if (key === "showWorkingTime") {
|
|
1841
|
+
faceliftConfig = { ...faceliftConfig, showWorkingTime: value as boolean };
|
|
1842
|
+
persistFaceliftConfig(extCtx);
|
|
1843
|
+
}
|
|
1747
1844
|
},
|
|
1748
1845
|
});
|
|
1749
1846
|
};
|
|
@@ -1752,6 +1849,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1752
1849
|
const lines = [
|
|
1753
1850
|
`config: ${getFaceliftConfigPath()}`,
|
|
1754
1851
|
`diffLayout: ${faceliftConfig.diffLayout}`,
|
|
1852
|
+
`workingTime: ${faceliftConfig.showWorkingTime ? "on" : "off"}`,
|
|
1755
1853
|
];
|
|
1756
1854
|
extCtx.ui.notify(`@wierdbytes/pi-facelift\n${lines.join("\n")}`, "info");
|
|
1757
1855
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wierdbytes/pi-facelift",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
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": [
|
package/read-rendering.test.ts
CHANGED
|
@@ -0,0 +1,292 @@
|
|
|
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 using formatClock rules", () => {
|
|
146
|
+
expect(workingMessageText(0)).toBe("Working... 0.0s");
|
|
147
|
+
expect(workingMessageText(1999)).toBe("Working... 2.0s");
|
|
148
|
+
expect(workingMessageText(65_000)).toBe("Working... 1m5s");
|
|
149
|
+
expect(workingMessageText(720_000)).toBe("Working... 12m0s");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("renders a muted history line with worked + total", () => {
|
|
153
|
+
const dim = (s: string) => `<dim>${s}</dim>`;
|
|
154
|
+
const entry: WorkingTimeEntry = { ms: 45_200, totalMs: 75_000, startedAt: 0, endedAt: 0 };
|
|
155
|
+
expect(workingTimeLine(entry, dim)).toBe("<dim>⏱ worked 45.2s (total: 1m15s)</dim>");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- extension wiring ---------------------------------------------------
|
|
160
|
+
|
|
161
|
+
class MockText {
|
|
162
|
+
private text: string;
|
|
163
|
+
constructor(text = "", _x = 0, _y = 0) {
|
|
164
|
+
this.text = text;
|
|
165
|
+
}
|
|
166
|
+
setText(value: string) {
|
|
167
|
+
this.text = value;
|
|
168
|
+
}
|
|
169
|
+
getText() {
|
|
170
|
+
return this.text;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function mockToolFactory(exec: any) {
|
|
175
|
+
return (_cwd: string) => ({
|
|
176
|
+
name: "mock",
|
|
177
|
+
description: "mock",
|
|
178
|
+
parameters: { type: "object", properties: {} },
|
|
179
|
+
execute: exec,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface MockCtx {
|
|
184
|
+
hasUI: boolean;
|
|
185
|
+
ui: { setWorkingMessage: ReturnType<typeof vi.fn> };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function loadExtension() {
|
|
189
|
+
const handlers = new Map<string, (event: unknown, ctx: MockCtx) => void>();
|
|
190
|
+
const entryRenderers = new Map<string, (entry: any, opts: any, theme: any) => unknown>();
|
|
191
|
+
const entries: Array<{ type: string; data: unknown }> = [];
|
|
192
|
+
|
|
193
|
+
const pi = {
|
|
194
|
+
registerTool: () => {},
|
|
195
|
+
registerCommand: () => {},
|
|
196
|
+
on: (name: string, h: (event: unknown, ctx: MockCtx) => void) => handlers.set(name, h),
|
|
197
|
+
registerEntryRenderer: (type: string, r: any) => entryRenderers.set(type, r),
|
|
198
|
+
appendEntry: (type: string, data: unknown) => entries.push({ type, data }),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
|
|
202
|
+
piFaceliftExtension(pi as any, {
|
|
203
|
+
sdk: {
|
|
204
|
+
createReadToolDefinition: mockToolFactory(noopExec),
|
|
205
|
+
getAgentDir: () => "/tmp/pi-facelift-test",
|
|
206
|
+
},
|
|
207
|
+
TextComponent: MockText as any,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const ctx: MockCtx = { hasUI: true, ui: { setWorkingMessage: vi.fn() } };
|
|
211
|
+
const fire = (name: string, event: unknown) => handlers.get(name)?.(event, ctx);
|
|
212
|
+
return { fire, ctx, entries, entryRenderers };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const asst = { message: { role: "assistant" } };
|
|
216
|
+
const user = { message: { role: "user" } };
|
|
217
|
+
|
|
218
|
+
describe("working-time wiring", () => {
|
|
219
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
220
|
+
|
|
221
|
+
it("worked counts request→message_end; total counts the whole run", () => {
|
|
222
|
+
vi.useFakeTimers();
|
|
223
|
+
const { fire, entries } = loadExtension();
|
|
224
|
+
|
|
225
|
+
fire("before_agent_start", {}); // anchors the total clock
|
|
226
|
+
vi.advanceTimersByTime(500); // pre-loop overhead
|
|
227
|
+
fire("agent_start", {});
|
|
228
|
+
fire("before_provider_request", {});
|
|
229
|
+
vi.advanceTimersByTime(5000);
|
|
230
|
+
fire("message_end", asst);
|
|
231
|
+
// tool gap — counts toward total, NOT worked
|
|
232
|
+
vi.advanceTimersByTime(3000);
|
|
233
|
+
fire("before_provider_request", {});
|
|
234
|
+
vi.advanceTimersByTime(2000);
|
|
235
|
+
fire("message_end", asst);
|
|
236
|
+
fire("agent_settled", {});
|
|
237
|
+
|
|
238
|
+
vi.useRealTimers();
|
|
239
|
+
expect(entries).toHaveLength(1);
|
|
240
|
+
expect(entries[0].type).toBe(WORKING_TIME_ENTRY);
|
|
241
|
+
const data = entries[0].data as WorkingTimeEntry;
|
|
242
|
+
expect(data.ms).toBe(7000);
|
|
243
|
+
expect(data.totalMs).toBe(10500); // 500 + 5000 + 3000 + 2000
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("ignores non-assistant message_end (does not close the segment early)", () => {
|
|
247
|
+
vi.useFakeTimers();
|
|
248
|
+
const { fire, entries } = loadExtension();
|
|
249
|
+
fire("before_agent_start", {});
|
|
250
|
+
fire("agent_start", {});
|
|
251
|
+
fire("before_provider_request", {});
|
|
252
|
+
vi.advanceTimersByTime(1000);
|
|
253
|
+
fire("message_end", user); // stray non-assistant end — ignored
|
|
254
|
+
vi.advanceTimersByTime(1000);
|
|
255
|
+
fire("message_end", asst);
|
|
256
|
+
fire("agent_settled", {});
|
|
257
|
+
vi.useRealTimers();
|
|
258
|
+
expect(entries).toHaveLength(1);
|
|
259
|
+
expect((entries[0].data as WorkingTimeEntry).ms).toBe(2000);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("updates the live working message from request send and restores on settle", () => {
|
|
263
|
+
vi.useFakeTimers();
|
|
264
|
+
const { fire, ctx } = loadExtension();
|
|
265
|
+
fire("before_agent_start", {});
|
|
266
|
+
fire("agent_start", {});
|
|
267
|
+
fire("before_provider_request", {});
|
|
268
|
+
vi.advanceTimersByTime(2000);
|
|
269
|
+
const calls = ctx.ui.setWorkingMessage.mock.calls.map((c) => c[0]);
|
|
270
|
+
expect(calls[0]).toBe("Working... 0.0s");
|
|
271
|
+
expect(calls).toContain("Working... 2.0s");
|
|
272
|
+
fire("message_end", asst);
|
|
273
|
+
fire("agent_settled", {});
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
// last call restores the default (undefined)
|
|
276
|
+
const last = ctx.ui.setWorkingMessage.mock.calls.at(-1);
|
|
277
|
+
expect(last?.[0]).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("registers an entry renderer that produces a dim worked+total line", () => {
|
|
281
|
+
const { entryRenderers } = loadExtension();
|
|
282
|
+
const renderer = entryRenderers.get(WORKING_TIME_ENTRY);
|
|
283
|
+
expect(renderer).toBeDefined();
|
|
284
|
+
const theme = { fg: (_k: string, s: string) => `dim(${s})` };
|
|
285
|
+
const out = renderer!(
|
|
286
|
+
{ data: { ms: 1500, totalMs: 4000, startedAt: 0, endedAt: 0 } },
|
|
287
|
+
{ expanded: false },
|
|
288
|
+
theme,
|
|
289
|
+
) as MockText;
|
|
290
|
+
expect(out.getText()).toBe("dim(⏱ worked 1.5s (total: 4.0s))");
|
|
291
|
+
});
|
|
292
|
+
});
|
package/working-time.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
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... 1m5s`. Uses the same
|
|
125
|
+
* `formatClock` rules as the durable history line so the live and final
|
|
126
|
+
* displays stay consistent. The animated spinner frames are drawn by pi
|
|
127
|
+
* separately (via `setWorkingIndicator`), so this only supplies the
|
|
128
|
+
* trailing text — preserving whatever spinner the user/other extensions
|
|
129
|
+
* have configured.
|
|
130
|
+
*/
|
|
131
|
+
export function workingMessageText(elapsedMs: number): string {
|
|
132
|
+
return `Working... ${formatClock(elapsedMs)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Format a duration as a compact clock string.
|
|
137
|
+
*
|
|
138
|
+
* - Sub-minute durations keep one decimal: `45.2s`.
|
|
139
|
+
* - Longer durations use integer `h`/`m`/`s` parts and always end in
|
|
140
|
+
* seconds. Leading zero parts are dropped — `0h12m0s` → `12m0s`,
|
|
141
|
+
* `0h0m5s` → `5s` — but once a part is present every following part is
|
|
142
|
+
* shown, interior zeros included (`1h0m5s`, `1h0m0s`).
|
|
143
|
+
*/
|
|
144
|
+
export function formatClock(ms: number): string {
|
|
145
|
+
const totalSec = Math.max(0, ms) / 1000;
|
|
146
|
+
if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
|
|
147
|
+
const totalSecInt = Math.floor(totalSec);
|
|
148
|
+
const hours = Math.floor(totalSecInt / 3600);
|
|
149
|
+
const min = Math.floor((totalSecInt % 3600) / 60);
|
|
150
|
+
const sec = totalSecInt % 60;
|
|
151
|
+
let out = "";
|
|
152
|
+
if (hours > 0) out += `${hours}h`;
|
|
153
|
+
if (hours > 0 || min > 0) out += `${min}m`;
|
|
154
|
+
return `${out}${sec}s`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render the durable entry body as a plain muted line (no frame):
|
|
159
|
+
* `⏱ worked 45.2s (total: 1m15s)`.
|
|
160
|
+
*/
|
|
161
|
+
export function workingTimeLine(entry: WorkingTimeEntry, dim: (s: string) => string): string {
|
|
162
|
+
return dim(`⏱ worked ${formatClock(entry.ms)} (total: ${formatClock(entry.totalMs)})`);
|
|
163
|
+
}
|
|
@@ -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, {
|