@wierdbytes/pi-facelift 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.3.2",
3
+ "version": "0.4.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": [
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@shikijs/cli": "^4.0.2",
56
- "@wierdbytes/pi-common": "^0.3.0"
56
+ "@wierdbytes/pi-common": "^0.4.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@earendil-works/pi-ai": ">=0.74.0",
package/paths.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Filesystem paths for @wierdbytes/pi-facelift.
3
+ *
4
+ * Per-package state lives under `~/.pi/agent/wierd-facelift/` (or
5
+ * `${PI_AGENT_DIR}/wierd-facelift/` if the env var is set):
6
+ *
7
+ * - `config.json` — user-editable settings (see config.ts). Today
8
+ * this is just the `diffLayout` preference; more knobs (themes,
9
+ * icon mode, max preview lines, etc.) can be added later without
10
+ * changing the directory layout.
11
+ *
12
+ * The directory is created lazily on first write — `ensureFaceliftDir`
13
+ * is the canonical entry point. We never call `mkdirSync` from module
14
+ * load so test runners and unrelated tooling don't accidentally touch
15
+ * the agent dir.
16
+ */
17
+
18
+ import { existsSync, mkdirSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ const PACKAGE_DIRNAME = "wierd-facelift";
23
+
24
+ /** Resolve the pi agent root (`PI_AGENT_DIR` if set, otherwise `~/.pi/agent`). */
25
+ export function piAgentDir(): string {
26
+ const fromEnv = process.env.PI_AGENT_DIR?.trim();
27
+ if (fromEnv) return fromEnv;
28
+ return join(homedir(), ".pi", "agent");
29
+ }
30
+
31
+ /** Per-package state directory: `<piAgentDir>/wierd-facelift`. */
32
+ export function faceliftDir(): string {
33
+ return join(piAgentDir(), PACKAGE_DIRNAME);
34
+ }
35
+
36
+ /** Path to the persisted config JSON. */
37
+ export function configPath(): string {
38
+ return join(faceliftDir(), "config.json");
39
+ }
40
+
41
+ /**
42
+ * Ensure the package directory exists. Idempotent. Caller should invoke
43
+ * this once before the first config save.
44
+ */
45
+ export function ensureFaceliftDir(): void {
46
+ const dir = faceliftDir();
47
+ if (!existsSync(dir)) {
48
+ mkdirSync(dir, { recursive: true });
49
+ }
50
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Visual smoke test for the new write/edit diff renderer.
3
+ *
4
+ * Renders a representative split-view diff inside an open-right facelift
5
+ * frame so the result can be eyeballed against the original pi-diff
6
+ * screenshot. Not run by CI — invoked manually with:
7
+ *
8
+ * bun run packages/facelift/scripts/diff-demo.ts
9
+ */
10
+
11
+ import {
12
+ applyDiffPalette,
13
+ canRenderSplit,
14
+ getDiffLayoutPreference,
15
+ lang,
16
+ parseDiff,
17
+ renderSplit,
18
+ resolveDiffColors,
19
+ summarize,
20
+ type DiffLayout,
21
+ } from "@wierdbytes/pi-common/diff";
22
+ import {
23
+ frameResultWithBottomLabel,
24
+ frameTop,
25
+ } from "@wierdbytes/pi-common/tool-frame";
26
+
27
+ applyDiffPalette();
28
+
29
+ // Minimal theme stub. By default we mimic tokyo-night-ish bg tokens so the
30
+ // auto-derived diff bg tints (BG_DEL/BG_ADD/BG_BASE) blend with each other.
31
+ // Pass FACELIFT_DEMO_PLAIN=1 to fall back to default terminal bg for the
32
+ // uncommon case where you want to inspect raw output.
33
+ const plain = process.env.FACELIFT_DEMO_PLAIN === "1";
34
+ const theme = plain
35
+ ? ({
36
+ fg: (_key: string, text: string) => text,
37
+ bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
38
+ getFgAnsi: () => "",
39
+ getBgAnsi: () => "",
40
+ } as unknown as Parameters<typeof frameTop>[2])
41
+ : ({
42
+ fg: (_key: string, text: string) => text,
43
+ bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
44
+ getFgAnsi: (key: string) => {
45
+ if (key === "toolDiffAdded") return "\x1b[38;2;100;180;120m"; // green
46
+ if (key === "toolDiffRemoved") return "\x1b[38;2;200;100;100m"; // red
47
+ return "";
48
+ },
49
+ getBgAnsi: (key: string) => {
50
+ if (key === "toolSuccessBg") return "\x1b[48;2;26;30;46m"; // tokyo-night-ish slate
51
+ if (key === "toolErrorBg") return "\x1b[48;2;46;26;30m";
52
+ return "";
53
+ },
54
+ } as unknown as Parameters<typeof frameTop>[2]);
55
+
56
+ const oldText = `if len(blockers) == 0 {
57
+ return nil
58
+ }
59
+
60
+ tx, err := s.db.BeginTx(ctx, nil)
61
+ if err != nil {
62
+ return fmt.Errorf("begin tx: %w", err)
63
+ `;
64
+
65
+ const newText = `if len(blockers) == 0 {
66
+ return nil
67
+ }
68
+ return retryOnBusy(ctx, func() error { return s.blockOnce(ctx, id, blockers) })
69
+ }
70
+
71
+ func (s *Store) blockOnce(ctx context.Context, id string, blockers []string) error {
72
+ tx, err := s.db.BeginTx(ctx, nil)
73
+ if err != nil {
74
+ return fmt.Errorf("begin tx: %w", err)
75
+ `;
76
+
77
+ const diff = parseDiff(oldText, newText);
78
+ const colors = resolveDiffColors(theme as never);
79
+ const width = Number(process.env.COLUMNS) || 200;
80
+ const innerW = width - 1;
81
+
82
+ // Mirror facelift's `decideDiffLayout` so the demo also respects
83
+ // `DIFF_LAYOUT=...` / `diffLayout` settings when invoked manually.
84
+ const pref = getDiffLayoutPreference();
85
+ let layout: DiffLayout | undefined;
86
+ if (pref === "split") layout = "split";
87
+ else if (pref === "unified") layout = "unified";
88
+ else if (pref === "per-edit") layout = undefined;
89
+ else layout = canRenderSplit(diff, innerW, 60) ? "split" : "unified";
90
+
91
+ const title = `\x1b[1medit\x1b[22m internal/store/doltlite/deps.go`;
92
+ console.log(frameTop(title, "success", theme, width));
93
+
94
+ const body = await renderSplit(diff, lang("foo.go"), 60, colors, innerW, { frameless: true, layout });
95
+ const label = `1 edit ${summarize(diff.added, diff.removed)} (${diff.lines.length} diff lines)`;
96
+ console.log(frameResultWithBottomLabel(body, label, "success", theme, width));
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Regression tests for the per-path mutation lock that pairs the
3
+ * pre-execute file snapshot with the underlying tool's execute() call.
4
+ *
5
+ * Bug history: when two `write` (or `edit`) calls for the *same* path
6
+ * landed in the agent's tool queue concurrently, both wrappers ran
7
+ *
8
+ * oldContent = readFileSync(path)
9
+ * result = await origExecute(...)
10
+ *
11
+ * in parallel. The built-in tool serialized the actual writes, but both
12
+ * wrappers had already captured the same pre-state snapshot — so the
13
+ * second wrapper rendered its diff against the *original* file rather
14
+ * than against whatever the first wrapper had just persisted.
15
+ *
16
+ * The fix routes snapshot + execute through `serializePerPath()`, an
17
+ * in-process per-path promise chain. These tests pin that ordering by
18
+ * making the mock origExecute() the thing that actually updates the
19
+ * file on disk: if the snapshot ran before our chained predecessor's
20
+ * execute(), the second snapshot would be the original contents and
21
+ * the diff details would prove it.
22
+ */
23
+
24
+ import { describe, expect, it } from "vitest";
25
+
26
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+
30
+ import piFaceliftExtension from "./index.ts";
31
+
32
+ class MockText {
33
+ private text = "";
34
+ constructor(_text = "", _x = 0, _y = 0) {}
35
+ setText(value: string) {
36
+ this.text = value;
37
+ }
38
+ getText() {
39
+ return this.text;
40
+ }
41
+ }
42
+
43
+ function mockToolFactory(exec: any) {
44
+ return (_cwd: string) => ({
45
+ name: "mock",
46
+ description: "mock",
47
+ parameters: { type: "object", properties: {} },
48
+ execute: exec,
49
+ });
50
+ }
51
+
52
+ function loadTools(opts: { writeExec?: any; editExec?: any } = {}) {
53
+ const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
54
+ const tools = new Map<string, any>();
55
+ const pi = {
56
+ registerTool: (tool: any) => tools.set(tool.name, tool),
57
+ registerCommand: () => {},
58
+ on: () => {},
59
+ };
60
+
61
+ piFaceliftExtension(pi, {
62
+ sdk: {
63
+ createReadToolDefinition: mockToolFactory(noopExec),
64
+ createBashToolDefinition: mockToolFactory(noopExec),
65
+ createLsToolDefinition: mockToolFactory(noopExec),
66
+ createFindToolDefinition: mockToolFactory(noopExec),
67
+ createGrepToolDefinition: mockToolFactory(noopExec),
68
+ createWriteToolDefinition: mockToolFactory(opts.writeExec ?? noopExec),
69
+ createEditToolDefinition: mockToolFactory(opts.editExec ?? noopExec),
70
+ getAgentDir: () => "/tmp/pi-facelift-test",
71
+ },
72
+ TextComponent: MockText,
73
+ });
74
+
75
+ return tools;
76
+ }
77
+
78
+ interface DiffLine {
79
+ type: "ctx" | "add" | "del" | "sep";
80
+ oldNum: number | null;
81
+ newNum: number | null;
82
+ content: string;
83
+ }
84
+
85
+ interface WriteDiffDetails {
86
+ _type: "writeDiff";
87
+ filePath: string;
88
+ diff: { lines: DiffLine[]; added: number; removed: number };
89
+ }
90
+
91
+ interface EditDiffDetails {
92
+ _type: "editDiff";
93
+ edits: Array<{ oldText: string; newText: string; diff: { lines: DiffLine[] } }>;
94
+ }
95
+
96
+ describe("write execute: per-path snapshot/execute serialization", () => {
97
+ it("the second concurrent write diffs against the first write's result, not the original file", async () => {
98
+ const dir = mkdtempSync(join(tmpdir(), "facelift-write-concur-"));
99
+ const file = join(dir, "state.txt");
100
+ writeFileSync(file, "A\n", "utf-8");
101
+
102
+ // `writeExec` is what the agent SDK's built-in write tool would
103
+ // do: actually persist `params.content` to disk. By doing this
104
+ // synchronously inside the mock we make the "second snapshot
105
+ // must run after the first execute" property *observable* —
106
+ // the resulting `details.diff` shows which baseline was used.
107
+ const writeExec = async (
108
+ _tid: string,
109
+ params: { path: string; content: string },
110
+ ) => {
111
+ // Yield once so a parallel caller has a chance to race with us.
112
+ await Promise.resolve();
113
+ writeFileSync(params.path, params.content, "utf-8");
114
+ return { content: [{ type: "text", text: `wrote ${params.path}` }] };
115
+ };
116
+ const tools = loadTools({ writeExec });
117
+ const writeTool = tools.get("write");
118
+
119
+ // Fire both calls without awaiting — the lock should serialize
120
+ // them so the second one sees "B\n", not "A\n".
121
+ const [r1, r2] = await Promise.all([
122
+ writeTool.execute("t1", { path: file, content: "B\n" }, null, null, {}),
123
+ writeTool.execute("t2", { path: file, content: "C\n" }, null, null, {}),
124
+ ]);
125
+
126
+ const d1 = r1.details as WriteDiffDetails;
127
+ const d2 = r2.details as WriteDiffDetails;
128
+
129
+ expect(d1._type).toBe("writeDiff");
130
+ expect(d2._type).toBe("writeDiff");
131
+
132
+ // First call: A → B. One deletion of "A", one addition of "B".
133
+ const d1Del = d1.diff.lines.find((l) => l.type === "del");
134
+ const d1Add = d1.diff.lines.find((l) => l.type === "add");
135
+ expect(d1Del?.content).toBe("A");
136
+ expect(d1Add?.content).toBe("B");
137
+
138
+ // Second call: with the bug, oldContent would still be "A" and
139
+ // the diff would say "A → C". With the lock, oldContent is the
140
+ // state the first call left behind ("B"), so the diff is B → C.
141
+ const d2Del = d2.diff.lines.find((l) => l.type === "del");
142
+ const d2Add = d2.diff.lines.find((l) => l.type === "add");
143
+ expect(d2Del?.content).toBe("B");
144
+ expect(d2Add?.content).toBe("C");
145
+
146
+ // And the file on disk reflects the second write.
147
+ expect(readFileSync(file, "utf-8")).toBe("C\n");
148
+
149
+ rmSync(dir, { recursive: true, force: true });
150
+ });
151
+
152
+ it("a failing first write does not poison subsequent writes to the same path", async () => {
153
+ const dir = mkdtempSync(join(tmpdir(), "facelift-write-poison-"));
154
+ const file = join(dir, "state.txt");
155
+ writeFileSync(file, "A\n", "utf-8");
156
+
157
+ let call = 0;
158
+ const writeExec = async (
159
+ _tid: string,
160
+ params: { path: string; content: string },
161
+ ) => {
162
+ call += 1;
163
+ await Promise.resolve();
164
+ if (call === 1) throw new Error("boom");
165
+ writeFileSync(params.path, params.content, "utf-8");
166
+ return { content: [{ type: "text", text: "ok" }] };
167
+ };
168
+ const tools = loadTools({ writeExec });
169
+ const writeTool = tools.get("write");
170
+
171
+ // First call rejects; the chain must still let the second call
172
+ // proceed and snapshot the (untouched) "A\n" baseline.
173
+ const p1 = writeTool
174
+ .execute("t1", { path: file, content: "B\n" }, null, null, {})
175
+ .catch((e: Error) => e);
176
+ const p2 = writeTool.execute("t2", { path: file, content: "C\n" }, null, null, {});
177
+ const [r1, r2] = await Promise.all([p1, p2]);
178
+
179
+ expect(r1).toBeInstanceOf(Error);
180
+ const d2 = (r2 as any).details as WriteDiffDetails;
181
+ expect(d2._type).toBe("writeDiff");
182
+ // First call never wrote, so the snapshot for the second call
183
+ // is the original "A\n".
184
+ const del = d2.diff.lines.find((l) => l.type === "del");
185
+ const add = d2.diff.lines.find((l) => l.type === "add");
186
+ expect(del?.content).toBe("A");
187
+ expect(add?.content).toBe("C");
188
+
189
+ rmSync(dir, { recursive: true, force: true });
190
+ });
191
+ });
192
+
193
+ describe("edit execute: per-path snapshot/execute serialization", () => {
194
+ it("the second concurrent edit sees the file state produced by the first edit", async () => {
195
+ const dir = mkdtempSync(join(tmpdir(), "facelift-edit-concur-"));
196
+ const file = join(dir, "src.ts");
197
+ const original = ["alpha", "beta", "gamma"].join("\n");
198
+ writeFileSync(file, original, "utf-8");
199
+
200
+ // Mock built-in edit: replace oldText → newText in the file so
201
+ // the second snapshot can prove it ran after the first execute.
202
+ const editExec = async (
203
+ _tid: string,
204
+ params: { path: string; edits: Array<{ oldText: string; newText: string }> },
205
+ ) => {
206
+ await Promise.resolve();
207
+ let body = readFileSync(params.path, "utf-8");
208
+ for (const e of params.edits) body = body.replace(e.oldText, e.newText);
209
+ writeFileSync(params.path, body, "utf-8");
210
+ return { content: [{ type: "text", text: "ok" }] };
211
+ };
212
+ const tools = loadTools({ editExec });
213
+ const editTool = tools.get("edit");
214
+
215
+ const [r1, r2] = await Promise.all([
216
+ editTool.execute(
217
+ "t1",
218
+ { path: file, edits: [{ oldText: "alpha", newText: "ALPHA" }] },
219
+ null,
220
+ null,
221
+ {},
222
+ ),
223
+ // Targets the *result* of edit 1. Only succeeds if the
224
+ // snapshot for edit 2 happens after edit 1's execute().
225
+ editTool.execute(
226
+ "t2",
227
+ { path: file, edits: [{ oldText: "ALPHA", newText: "Alpha!" }] },
228
+ null,
229
+ null,
230
+ {},
231
+ ),
232
+ ]);
233
+
234
+ const d2 = r2.details as EditDiffDetails;
235
+ expect(d2._type).toBe("editDiff");
236
+ // The edit operated on the line that previously was "alpha"
237
+ // (file line 1). Without the lock the snapshot would still show
238
+ // "alpha", `findLineOffset` for "ALPHA" would miss, and the
239
+ // diff would silently number from line 1 of a fake baseline.
240
+ const del = d2.edits[0].diff.lines.find((l) => l.type === "del");
241
+ const add = d2.edits[0].diff.lines.find((l) => l.type === "add");
242
+ expect(del?.content).toBe("ALPHA");
243
+ expect(add?.content).toBe("Alpha!");
244
+ expect(del?.oldNum).toBe(1);
245
+ expect(add?.newNum).toBe(1);
246
+
247
+ // Final on-disk content reflects both edits applied in order.
248
+ expect(readFileSync(file, "utf-8")).toBe(["Alpha!", "beta", "gamma"].join("\n"));
249
+
250
+ rmSync(dir, { recursive: true, force: true });
251
+ });
252
+
253
+ it("does not serialize unrelated paths against each other", async () => {
254
+ const dir = mkdtempSync(join(tmpdir(), "facelift-edit-indep-"));
255
+ const fileA = join(dir, "a.txt");
256
+ const fileB = join(dir, "b.txt");
257
+ writeFileSync(fileA, "a\n", "utf-8");
258
+ writeFileSync(fileB, "b\n", "utf-8");
259
+
260
+ // First call to `fileA` is intentionally slow. Second call to
261
+ // `fileB` must NOT wait for it.
262
+ let order: string[] = [];
263
+ const editExec = async (
264
+ _tid: string,
265
+ params: { path: string; edits: Array<{ oldText: string; newText: string }> },
266
+ ) => {
267
+ order.push(`start:${params.path}`);
268
+ if (params.path === fileA) await new Promise((r) => setTimeout(r, 30));
269
+ order.push(`end:${params.path}`);
270
+ return { content: [{ type: "text", text: "ok" }] };
271
+ };
272
+ const tools = loadTools({ editExec });
273
+ const editTool = tools.get("edit");
274
+
275
+ await Promise.all([
276
+ editTool.execute(
277
+ "a",
278
+ { path: fileA, edits: [{ oldText: "a", newText: "aa" }] },
279
+ null,
280
+ null,
281
+ {},
282
+ ),
283
+ editTool.execute(
284
+ "b",
285
+ { path: fileB, edits: [{ oldText: "b", newText: "bb" }] },
286
+ null,
287
+ null,
288
+ {},
289
+ ),
290
+ ]);
291
+
292
+ // fileB should finish before fileA — proves the lock is
293
+ // per-path, not a global mutex.
294
+ const endA = order.indexOf(`end:${fileA}`);
295
+ const endB = order.indexOf(`end:${fileB}`);
296
+ expect(endB).toBeLessThan(endA);
297
+
298
+ rmSync(dir, { recursive: true, force: true });
299
+ });
300
+ });