@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/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - **New**: GitHub-style split/unified diff renderer for the `write`
6
+ and `edit` tools, ported from `@heyhuynhgiabuu/pi-diff` and rebuilt
7
+ on `@wierdbytes/pi-common/diff` so the body slots cleanly inside
8
+ the facelift open-right frame. Each edit in a multi-edit call gets
9
+ its own `Edit i/N` block with the per-edit summary; the overall
10
+ `+A -B (M diff lines)` summary sits in the bottom border. Word-
11
+ level emphasis (`Diff.diffWords`) is layered on paired 1:1
12
+ del/add lines.
13
+ - **New**: per-edit line-number shifting. `parseDiff` only sees a
14
+ snippet, so a previous pass numbered every edit from line 1
15
+ regardless of where it lived in the file. Now the wrapper
16
+ snapshots the file before `execute`, resolves each `oldText` via
17
+ `indexOf`, and shifts `oldNum`/`newNum` accordingly. A
18
+ `cumulativeDelta` keeps multi-edit new-side numbers consistent for
19
+ the common in-order case.
20
+ - **New**: persistent per-package config at
21
+ `~/.pi/agent/wierd-facelift/config.json` (or
22
+ `${PI_AGENT_DIR}/wierd-facelift/config.json`). Schema today is just
23
+ `{ diffLayout: 'consistent' | 'split' | 'unified' | 'per-edit' }`
24
+ but designed to accept more knobs without breaking older files.
25
+ Sanitised on every load — hand-edits with typos fall back to
26
+ defaults.
27
+ - **New**: `/facelift` slash command — bare invocation opens a
28
+ `@wierdbytes/pi-common`-backed settings overlay (matches
29
+ `/voice` / `/web` look). Subcommands: `status` prints config +
30
+ path, `reset` restores defaults.
31
+ - **Change**: `consistent` is the default `diffLayout`. All edits in
32
+ one tool call share one layout: split iff every diff fits without
33
+ excessive line wrapping, otherwise unified for all. Stops
34
+ `Edit 1 split, Edit 2 unified` mixed renders. `DIFF_LAYOUT=...` env
35
+ var seeds the config on first run.
36
+ - **Fix**: bottom frame border no longer inherits the diff body's
37
+ background. The diff module's `RST` no longer carries `BG_BASE`
38
+ through trailing newlines, so the closing `╰──…` of the open-right
39
+ frame stays on the same bg as the opening `╭──…`.
40
+ - **Fix**: right-edge "notch" in split rows on odd terminal widths.
41
+ Each row is padded with one `\x1b[49m` cell so its visible width
42
+ always matches the frame chrome above/below.
43
+ - **Dep**: bumps `@wierdbytes/pi-common` from `^0.3.0` to `^0.4.0`
44
+ (for the new `/diff` subpath). No env-only knobs were removed;
45
+ the only retained env var is `DIFF_LAYOUT` (one-shot seed).
46
+
3
47
  ## 0.3.2
4
48
 
5
49
  - **Change**: `bash` tool titles always render the full command, even
package/README.md CHANGED
@@ -31,6 +31,14 @@ tool output.
31
31
  - **`find` / `grep`** — grouped / highlighted rendering on top of pi's
32
32
  built-in tool implementations (no extra search backend, no extra
33
33
  dependencies).
34
+ - **`write` / `edit`** — GitHub-style side-by-side diff with syntax
35
+ highlighting and word-level emphasis (powered by
36
+ [`@wierdbytes/pi-common/diff`][cd]). All edits in one tool call share
37
+ a single layout by default (split when every diff fits, unified when
38
+ any would wrap) so `Edit 1 split, Edit 2 unified` mixed renders
39
+ don't happen — configurable via `diffLayout` (see below).
40
+
41
+ [cd]: ../common/README.md#diff
34
42
 
35
43
  ## Install
36
44
 
@@ -52,7 +60,53 @@ set -g allow-passthrough on
52
60
 
53
61
  ## Configuration
54
62
 
55
- All configuration is via environment variables — no settings file:
63
+ ### `/facelift` slash command
64
+
65
+ Run `/facelift` to open the settings overlay. Bare invocation opens the
66
+ modal; subcommands print or reset state:
67
+
68
+ | Command | Effect |
69
+ | ------------------ | --------------------------------------------------------------- |
70
+ | `/facelift` | Open the settings overlay. |
71
+ | `/facelift status` | Print current config + the config-file path. |
72
+ | `/facelift reset` | Reset every knob to defaults and persist. |
73
+
74
+ The overlay matches the look of `/voice`, `/web`, `/statusline` from the
75
+ same monorepo. Changes are persisted to disk immediately on each tweak.
76
+
77
+ ### Config file
78
+
79
+ Per-package state lives at:
80
+
81
+ ```
82
+ ~/.pi/agent/wierd-facelift/config.json
83
+ ```
84
+
85
+ (or `${PI_AGENT_DIR}/wierd-facelift/config.json` if `PI_AGENT_DIR` is
86
+ set). The file is seeded on first run and re-sanitised on every load,
87
+ so hand-edits with typos won't crash the extension — unknown fields
88
+ fall back to defaults.
89
+
90
+ Current schema:
91
+
92
+ ```jsonc
93
+ {
94
+ // "consistent" (default): one layout per tool call. If every diff
95
+ // in the call fits without excessive line wrapping → split; else
96
+ // → unified for all. Avoids `Edit 1 split, Edit 2 unified` mixed
97
+ // renders in a single edit call.
98
+ // "split" : always side-by-side, even when long lines wrap.
99
+ // "unified" : always stacked single-column.
100
+ // "per-edit" : each diff picks independently (original pi-diff
101
+ // behaviour; can produce mixed layouts).
102
+ "diffLayout": "consistent"
103
+ }
104
+ ```
105
+
106
+ ### Environment variables (cosmetic knobs)
107
+
108
+ These stay env-only because they're either rendering tunables (Shiki,
109
+ icons, image protocols) or boot-time toggles:
56
110
 
57
111
  | Variable | Default | Notes |
58
112
  | ------------------------------ | --------------- | ----------------------------------------------------------------------------- |
@@ -62,6 +116,7 @@ All configuration is via environment variables — no settings file:
62
116
  | `FACELIFT_CACHE_LIMIT` | `128` | Max number of highlighted blocks held in memory. |
63
117
  | `FACELIFT_ICONS` | `nerd` | Set to `none` / `off` to disable Nerd Font icons in `ls`/`find`/`grep`. |
64
118
  | `FACELIFT_IMAGE_PROTOCOL` | auto | Force `kitty` / `iterm2` / `none`. Auto-detected from `$TERM_PROGRAM` etc. |
119
+ | `DIFF_LAYOUT` | (none) | One-shot override for `diffLayout` on first-run seeding. |
65
120
 
66
121
  ## Development
67
122
 
package/config.test.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Round-trip tests for @wierdbytes/pi-facelift's persistent config.
3
+ *
4
+ * Mirrors the contract enforced by packages/voice/config.test.ts and
5
+ * packages/web/config.test.ts:
6
+ *
7
+ * • `envDefaults()` honours the DIFF_LAYOUT env var when valid, falls
8
+ * back to "consistent" otherwise.
9
+ * • `sanitize()` (via `loadConfig`) drops unknown / malformed fields
10
+ * instead of throwing.
11
+ * • `saveConfig` + `loadConfig` round-trip every valid layout value
12
+ * through disk without mutation.
13
+ * • `loadOrInitConfig` seeds a missing file from env defaults and
14
+ * writes it to disk.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+
19
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import {
24
+ envDefaults,
25
+ loadConfig,
26
+ loadOrInitConfig,
27
+ saveConfig,
28
+ VALID_DIFF_LAYOUTS,
29
+ type WierdFaceliftConfig,
30
+ } from "./config.ts";
31
+
32
+ let dir: string;
33
+ let configFile: string;
34
+ const savedEnv: Record<string, string | undefined> = {};
35
+
36
+ beforeEach(() => {
37
+ dir = mkdtempSync(join(tmpdir(), "facelift-config-"));
38
+ configFile = join(dir, "config.json");
39
+ savedEnv.DIFF_LAYOUT = process.env.DIFF_LAYOUT;
40
+ delete process.env.DIFF_LAYOUT;
41
+ });
42
+
43
+ afterEach(() => {
44
+ if (savedEnv.DIFF_LAYOUT !== undefined) process.env.DIFF_LAYOUT = savedEnv.DIFF_LAYOUT;
45
+ else delete process.env.DIFF_LAYOUT;
46
+ rmSync(dir, { recursive: true, force: true });
47
+ });
48
+
49
+ describe("envDefaults", () => {
50
+ it("returns 'consistent' when DIFF_LAYOUT is unset", () => {
51
+ expect(envDefaults()).toEqual({ diffLayout: "consistent" });
52
+ });
53
+
54
+ for (const layout of VALID_DIFF_LAYOUTS) {
55
+ it(`honours DIFF_LAYOUT=${layout}`, () => {
56
+ process.env.DIFF_LAYOUT = layout;
57
+ expect(envDefaults()).toEqual({ diffLayout: layout });
58
+ });
59
+ }
60
+
61
+ it("ignores invalid DIFF_LAYOUT values", () => {
62
+ process.env.DIFF_LAYOUT = "rainbow";
63
+ expect(envDefaults()).toEqual({ diffLayout: "consistent" });
64
+ });
65
+
66
+ it("is case-insensitive on DIFF_LAYOUT", () => {
67
+ process.env.DIFF_LAYOUT = "SPLIT";
68
+ expect(envDefaults()).toEqual({ diffLayout: "split" });
69
+ });
70
+ });
71
+
72
+ describe("loadConfig + saveConfig round-trip", () => {
73
+ for (const layout of VALID_DIFF_LAYOUTS) {
74
+ it(`persists diffLayout=${layout}`, () => {
75
+ saveConfig({ diffLayout: layout } as WierdFaceliftConfig, configFile);
76
+ expect(existsSync(configFile)).toBe(true);
77
+ expect(loadConfig(configFile)).toEqual({ diffLayout: layout });
78
+ });
79
+ }
80
+
81
+ it("sanitises unknown fields back to defaults", () => {
82
+ writeFileSync(
83
+ configFile,
84
+ JSON.stringify({ diffLayout: "consistent", iconMode: "rainbow", junk: 42 }),
85
+ );
86
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
87
+ });
88
+
89
+ it("rejects invalid diffLayout strings and falls back to default", () => {
90
+ writeFileSync(configFile, JSON.stringify({ diffLayout: "tutti-frutti" }));
91
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
92
+ });
93
+
94
+ it("returns defaults when the file is missing (no write)", () => {
95
+ expect(existsSync(configFile)).toBe(false);
96
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
97
+ expect(existsSync(configFile)).toBe(false);
98
+ });
99
+
100
+ it("returns defaults when the file is malformed JSON", () => {
101
+ writeFileSync(configFile, "{ not valid json");
102
+ expect(loadConfig(configFile)).toEqual({ diffLayout: "consistent" });
103
+ });
104
+
105
+ it("writes a trailing newline", () => {
106
+ saveConfig({ diffLayout: "split" } as WierdFaceliftConfig, configFile);
107
+ expect(readFileSync(configFile, "utf-8").endsWith("\n")).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("loadOrInitConfig", () => {
112
+ it("seeds a missing file from env defaults", () => {
113
+ expect(existsSync(configFile)).toBe(false);
114
+ const cfg = loadOrInitConfig(configFile);
115
+ expect(cfg).toEqual({ diffLayout: "consistent" });
116
+ expect(existsSync(configFile)).toBe(true);
117
+ expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "consistent" });
118
+ });
119
+
120
+ it("respects DIFF_LAYOUT when seeding", () => {
121
+ process.env.DIFF_LAYOUT = "unified";
122
+ const cfg = loadOrInitConfig(configFile);
123
+ expect(cfg).toEqual({ diffLayout: "unified" });
124
+ expect(JSON.parse(readFileSync(configFile, "utf-8"))).toEqual({ diffLayout: "unified" });
125
+ });
126
+
127
+ it("does not overwrite an existing file", () => {
128
+ writeFileSync(configFile, JSON.stringify({ diffLayout: "split" }));
129
+ process.env.DIFF_LAYOUT = "unified"; // would override on a fresh seed
130
+ const cfg = loadOrInitConfig(configFile);
131
+ expect(cfg).toEqual({ diffLayout: "split" });
132
+ });
133
+ });
package/config.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Persistent config for @wierdbytes/pi-facelift.
3
+ *
4
+ * Stored at `<piAgentDir>/wierd-facelift/config.json`. Pattern mirrors
5
+ * `packages/voice/config.ts` and `packages/web/config.ts`:
6
+ *
7
+ * - First-run load: file missing ⇒ seed from env, write to disk.
8
+ * - Subsequent loads: read JSON, `sanitize()` unknown fields back to
9
+ * defaults so a hand-edited file with typos doesn't crash the
10
+ * extension.
11
+ * - Saves write atomically (one JSON.stringify + trailing newline).
12
+ *
13
+ * The only knob we expose today is `diffLayout`, but the schema is
14
+ * designed to accept more fields (icon mode, max preview lines, etc.)
15
+ * without breaking older config files.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { dirname } from "node:path";
20
+
21
+ import { configPath as defaultConfigPath, ensureFaceliftDir } from "./paths.ts";
22
+
23
+ /**
24
+ * User-facing layout preference for the write/edit diff renderer.
25
+ *
26
+ * Mirrors `DiffLayoutPreference` from `@wierdbytes/pi-common/diff`. We
27
+ * re-declare it here so the config module has no runtime dependency on
28
+ * the diff package — the two values are kept in sync by the
29
+ * `decideDiffLayout` helper in `index.ts`.
30
+ */
31
+ export const VALID_DIFF_LAYOUTS = ["consistent", "split", "unified", "per-edit"] as const;
32
+ export type DiffLayoutPreference = (typeof VALID_DIFF_LAYOUTS)[number];
33
+ export const DEFAULT_DIFF_LAYOUT: DiffLayoutPreference = "consistent";
34
+
35
+ export interface WierdFaceliftConfig {
36
+ /**
37
+ * How to pick split-vs-unified for write/edit diffs:
38
+ *
39
+ * • `"consistent"` (default) — one layout per tool call. If every
40
+ * diff fits without excessive line wrapping → split; else →
41
+ * unified for all. Avoids `Edit 1 split, Edit 2 unified` mixed
42
+ * renders within one tool call.
43
+ * • `"split"` — always side-by-side, even when long lines
44
+ * wrap.
45
+ * • `"unified"` — always stacked single-column.
46
+ * • `"per-edit"` — each diff picks independently (original
47
+ * pi-diff behaviour; can produce mixed layouts in one call).
48
+ */
49
+ diffLayout: DiffLayoutPreference;
50
+ }
51
+
52
+ function isDiffLayout(value: unknown): value is DiffLayoutPreference {
53
+ return typeof value === "string" && (VALID_DIFF_LAYOUTS as readonly string[]).includes(value);
54
+ }
55
+
56
+ /**
57
+ * Seed defaults from the environment. `DIFF_LAYOUT` is the only env
58
+ * override today; everything else falls back to the hard-coded default.
59
+ *
60
+ * The env var is read at every defaults() call, not cached at module
61
+ * load, so callers can change it between tests / sub-processes without
62
+ * having to re-import the module.
63
+ */
64
+ export function envDefaults(): WierdFaceliftConfig {
65
+ const envLayout = process.env.DIFF_LAYOUT?.trim().toLowerCase();
66
+ const fromEnv = envLayout && isDiffLayout(envLayout) ? envLayout : undefined;
67
+ return {
68
+ diffLayout: fromEnv ?? DEFAULT_DIFF_LAYOUT,
69
+ };
70
+ }
71
+
72
+ function sanitize(raw: unknown): WierdFaceliftConfig {
73
+ const defaults = envDefaults();
74
+ if (!raw || typeof raw !== "object") return defaults;
75
+ const obj = raw as Record<string, unknown>;
76
+ const cfg: WierdFaceliftConfig = { ...defaults };
77
+ if (isDiffLayout(obj.diffLayout)) cfg.diffLayout = obj.diffLayout;
78
+ return cfg;
79
+ }
80
+
81
+ /**
82
+ * Canonical config path. Wrapper around `paths.ts` so callers (status
83
+ * commands, error messages) have a single import target.
84
+ */
85
+ export function getConfigPath(): string {
86
+ return defaultConfigPath();
87
+ }
88
+
89
+ /**
90
+ * Load config from disk. Missing / unreadable file ⇒ env-seeded
91
+ * defaults (no write). Unknown fields are silently dropped.
92
+ */
93
+ export function loadConfig(path: string = defaultConfigPath()): WierdFaceliftConfig {
94
+ try {
95
+ if (!existsSync(path)) return envDefaults();
96
+ const raw = readFileSync(path, "utf-8");
97
+ return sanitize(JSON.parse(raw));
98
+ } catch {
99
+ return envDefaults();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Persist config to disk. Creates the parent directory if missing.
105
+ * Returns the path written to so callers can echo it in a status
106
+ * message.
107
+ */
108
+ export function saveConfig(
109
+ cfg: WierdFaceliftConfig,
110
+ path: string = defaultConfigPath(),
111
+ ): string {
112
+ if (path === defaultConfigPath()) {
113
+ ensureFaceliftDir();
114
+ } else {
115
+ const dir = dirname(path);
116
+ if (dir && !existsSync(dir)) {
117
+ mkdirSync(dir, { recursive: true });
118
+ }
119
+ }
120
+ const out: Record<string, unknown> = {
121
+ diffLayout: cfg.diffLayout,
122
+ };
123
+ writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, "utf-8");
124
+ return path;
125
+ }
126
+
127
+ /**
128
+ * Load config, seeding the file if missing. Used at extension boot so
129
+ * users discover the config location naturally (via `/facelift` →
130
+ * "config: ...") even before they edit anything.
131
+ */
132
+ export function loadOrInitConfig(path: string = defaultConfigPath()): WierdFaceliftConfig {
133
+ if (!existsSync(path)) {
134
+ const seeded = envDefaults();
135
+ try {
136
+ saveConfig(seeded, path);
137
+ } catch {
138
+ // Non-fatal: fall back to in-memory defaults if disk write fails.
139
+ }
140
+ return seeded;
141
+ }
142
+ return loadConfig(path);
143
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Regression tests for `edit`-tool line-number shifting.
3
+ *
4
+ * Bug history: the wrapper called `parseDiff(op.oldText, op.newText)`
5
+ * with just the per-edit snippets, so every diff was numbered from
6
+ * line 1 \u2014 even when the edit modified line 50 of the file. The user
7
+ * screenshot showed `1 -`/`1 +` for a change that actually replaced
8
+ * line 3 of `README.md`.
9
+ *
10
+ * The fix snapshots the file BEFORE running the original edit
11
+ * `execute`, then for each edit shifts `oldNum`/`newNum` of every diff
12
+ * line by the file offset where `op.oldText` lives. A `cumulativeDelta`
13
+ * keeps the new-side numbers consistent when earlier edits in the same
14
+ * call add/remove lines.
15
+ *
16
+ * These tests pin both invariants so a future refactor can't silently
17
+ * drift the gutter numbers again.
18
+ */
19
+
20
+ import { describe, expect, it } from "vitest";
21
+
22
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ import piFaceliftExtension from "./index.ts";
27
+
28
+ class MockText {
29
+ private text = "";
30
+ constructor(_text = "", _x = 0, _y = 0) {}
31
+ setText(value: string) {
32
+ this.text = value;
33
+ }
34
+ getText() {
35
+ return this.text;
36
+ }
37
+ }
38
+
39
+ function mockToolFactory(exec: any) {
40
+ return (_cwd: string) => ({
41
+ name: "mock",
42
+ description: "mock",
43
+ parameters: { type: "object", properties: {} },
44
+ execute: exec,
45
+ });
46
+ }
47
+
48
+ function loadEditTool(editExec: any) {
49
+ const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
50
+ const tools = new Map<string, any>();
51
+ const pi = {
52
+ registerTool: (tool: any) => tools.set(tool.name, tool),
53
+ registerCommand: () => {},
54
+ on: () => {},
55
+ };
56
+
57
+ piFaceliftExtension(pi, {
58
+ sdk: {
59
+ createReadToolDefinition: mockToolFactory(noopExec),
60
+ createBashToolDefinition: mockToolFactory(noopExec),
61
+ createLsToolDefinition: mockToolFactory(noopExec),
62
+ createFindToolDefinition: mockToolFactory(noopExec),
63
+ createGrepToolDefinition: mockToolFactory(noopExec),
64
+ createWriteToolDefinition: mockToolFactory(noopExec),
65
+ createEditToolDefinition: mockToolFactory(editExec),
66
+ getAgentDir: () => "/tmp/pi-facelift-test",
67
+ },
68
+ TextComponent: MockText,
69
+ });
70
+
71
+ return tools.get("edit");
72
+ }
73
+
74
+ interface DiffLine {
75
+ type: "ctx" | "add" | "del" | "sep";
76
+ oldNum: number | null;
77
+ newNum: number | null;
78
+ content: string;
79
+ }
80
+
81
+ interface EditEntry {
82
+ oldText: string;
83
+ newText: string;
84
+ diff: { lines: DiffLine[]; added: number; removed: number };
85
+ }
86
+
87
+ interface EditDetails {
88
+ _type: "editDiff";
89
+ edits: EditEntry[];
90
+ }
91
+
92
+ describe("edit execute: per-edit line-number offsets", () => {
93
+ it("shifts diff line numbers to match where the edit lives in the file", async () => {
94
+ const dir = mkdtempSync(join(tmpdir(), "facelift-edit-lineno-"));
95
+ const file = join(dir, "README.md");
96
+ const original = [
97
+ "# pi-wierd-stuff", // 1
98
+ "", // 2
99
+ "Monorepo for various extensions for the [pi](https://github.com/badlogic/pi-mono) coding agent.", // 3
100
+ "", // 4
101
+ "## Packages", // 5
102
+ ].join("\n");
103
+ writeFileSync(file, original, "utf-8");
104
+
105
+ // Mock the original edit `execute` \u2014 we don't need it to actually
106
+ // rewrite the file. The wrapper reads the file BEFORE calling
107
+ // us, then attaches `details` based on its in-memory copy of
108
+ // the edits.
109
+ const editTool = loadEditTool(async () => ({
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: "Successfully replaced 1 block(s) in README.md.",
114
+ },
115
+ ],
116
+ }));
117
+
118
+ const oldLine =
119
+ "Monorepo for various extensions for the [pi](https://github.com/badlogic/pi-mono) coding agent.";
120
+ const newLine =
121
+ "Monorepo for various extensions for the [pi](https://github.com/earendil-works/pi) coding agent.";
122
+
123
+ const result = await editTool.execute(
124
+ "t1",
125
+ {
126
+ path: file,
127
+ edits: [{ oldText: oldLine, newText: newLine }],
128
+ },
129
+ null,
130
+ null,
131
+ {},
132
+ );
133
+
134
+ const details = result.details as EditDetails;
135
+ expect(details._type).toBe("editDiff");
136
+ expect(details.edits).toHaveLength(1);
137
+
138
+ const lines = details.edits[0].diff.lines;
139
+ const del = lines.find((l) => l.type === "del");
140
+ const add = lines.find((l) => l.type === "add");
141
+ expect(del?.oldNum).toBe(3);
142
+ expect(add?.newNum).toBe(3);
143
+
144
+ rmSync(dir, { recursive: true, force: true });
145
+ });
146
+
147
+ it("tracks cumulative line delta across multiple edits in one call", async () => {
148
+ const dir = mkdtempSync(join(tmpdir(), "facelift-edit-cumdelta-"));
149
+ const file = join(dir, "src.ts");
150
+ const original = [
151
+ "export const A = 1;", // 1
152
+ "export const B = 2;", // 2
153
+ "export const C = 3;", // 3
154
+ "export const D = 4;", // 4
155
+ "export const E = 5;", // 5
156
+ ].join("\n");
157
+ writeFileSync(file, original, "utf-8");
158
+
159
+ const editTool = loadEditTool(async () => ({
160
+ content: [{ type: "text", text: "Successfully replaced 2 block(s) in src.ts." }],
161
+ }));
162
+
163
+ const result = await editTool.execute(
164
+ "t2",
165
+ {
166
+ path: file,
167
+ edits: [
168
+ // Edit 1 at line 2: replace with two lines (net +1).
169
+ { oldText: "export const B = 2;", newText: "export const B = 2;\nexport const BB = 22;" },
170
+ // Edit 2 at line 4: simple in-place replacement (net 0).
171
+ { oldText: "export const D = 4;", newText: "export const D = 40;" },
172
+ ],
173
+ },
174
+ null,
175
+ null,
176
+ {},
177
+ );
178
+
179
+ const details = result.details as EditDetails;
180
+ expect(details.edits).toHaveLength(2);
181
+
182
+ // Edit 1: oldNum starts at line 2, newNum starts at line 2.
183
+ {
184
+ const lines = details.edits[0].diff.lines;
185
+ const del = lines.find((l) => l.type === "del");
186
+ const adds = lines.filter((l) => l.type === "add");
187
+ expect(del?.oldNum).toBe(2);
188
+ expect(adds.map((l) => l.newNum)).toEqual([2, 3]);
189
+ }
190
+
191
+ // Edit 2: oldNum at file line 4. newNum should already be
192
+ // shifted by the +1 introduced by Edit 1 \u2192 line 5.
193
+ {
194
+ const lines = details.edits[1].diff.lines;
195
+ const del = lines.find((l) => l.type === "del");
196
+ const add = lines.find((l) => l.type === "add");
197
+ expect(del?.oldNum).toBe(4);
198
+ expect(add?.newNum).toBe(5);
199
+ }
200
+
201
+ rmSync(dir, { recursive: true, force: true });
202
+ });
203
+
204
+ it("falls back to line 1 when the file can't be read (graceful, no throw)", async () => {
205
+ // Point at a path that does not exist. The wrapper should
206
+ // swallow the read error and leave diff numbers starting at 1
207
+ // rather than crashing the tool call.
208
+ const editTool = loadEditTool(async () => ({
209
+ content: [{ type: "text", text: "Successfully replaced 1 block(s) in missing.md." }],
210
+ }));
211
+
212
+ const result = await editTool.execute(
213
+ "t3",
214
+ {
215
+ path: "/tmp/this-path-does-not-exist-12345/missing.md",
216
+ edits: [{ oldText: "a", newText: "b" }],
217
+ },
218
+ null,
219
+ null,
220
+ {},
221
+ );
222
+
223
+ const details = result.details as EditDetails;
224
+ expect(details.edits).toHaveLength(1);
225
+ const lines = details.edits[0].diff.lines;
226
+ const del = lines.find((l) => l.type === "del");
227
+ const add = lines.find((l) => l.type === "add");
228
+ expect(del?.oldNum).toBe(1);
229
+ expect(add?.newNum).toBe(1);
230
+ });
231
+ });