@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 +44 -0
- package/README.md +56 -1
- package/config.test.ts +133 -0
- package/config.ts +143 -0
- package/edit-line-numbers.test.ts +231 -0
- package/index.ts +639 -3
- package/package.json +2 -2
- package/paths.ts +50 -0
- package/scripts/diff-demo.ts +96 -0
- package/write-edit-concurrency.test.ts +300 -0
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
|
-
|
|
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
|
+
});
|