@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/index.ts
CHANGED
|
@@ -24,14 +24,16 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import * as childProcess from "node:child_process";
|
|
27
|
-
import { readFileSync } from "node:fs";
|
|
28
|
-
import { basename, dirname, extname, join, relative } from "node:path";
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { basename, dirname, extname, join, relative, resolve as resolvePath } from "node:path";
|
|
29
29
|
|
|
30
30
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
31
31
|
import type {
|
|
32
32
|
AgentToolResult,
|
|
33
33
|
AgentToolUpdateCallback,
|
|
34
34
|
BashToolInput,
|
|
35
|
+
EditToolDetails,
|
|
36
|
+
EditToolInput,
|
|
35
37
|
ExtensionCommandContext,
|
|
36
38
|
ExtensionContext,
|
|
37
39
|
FindToolInput,
|
|
@@ -39,11 +41,36 @@ import type {
|
|
|
39
41
|
LsToolInput,
|
|
40
42
|
ReadToolInput,
|
|
41
43
|
ToolRenderResultOptions,
|
|
44
|
+
WriteToolInput,
|
|
42
45
|
} from "@earendil-works/pi-coding-agent";
|
|
43
46
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
44
47
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
45
48
|
import { codeToANSI } from "@shikijs/cli";
|
|
46
49
|
import { bundledThemes } from "shiki";
|
|
50
|
+
import {
|
|
51
|
+
applyDiffPalette,
|
|
52
|
+
canRenderSplit,
|
|
53
|
+
hlBlock as hlDiffBlock,
|
|
54
|
+
lang as diffLang,
|
|
55
|
+
parseDiff,
|
|
56
|
+
renderSplit,
|
|
57
|
+
resolveDiffColors,
|
|
58
|
+
summarize as summarizeDiff,
|
|
59
|
+
themeCacheKey,
|
|
60
|
+
type DiffColors,
|
|
61
|
+
type DiffLayout,
|
|
62
|
+
type ParsedDiff,
|
|
63
|
+
} from "@wierdbytes/pi-common/diff";
|
|
64
|
+
import { openSettingsModal, type Field } from "@wierdbytes/pi-common";
|
|
65
|
+
import {
|
|
66
|
+
envDefaults as faceliftEnvDefaults,
|
|
67
|
+
getConfigPath as getFaceliftConfigPath,
|
|
68
|
+
loadOrInitConfig as loadFaceliftConfig,
|
|
69
|
+
saveConfig as saveFaceliftConfig,
|
|
70
|
+
VALID_DIFF_LAYOUTS,
|
|
71
|
+
type DiffLayoutPreference,
|
|
72
|
+
type WierdFaceliftConfig,
|
|
73
|
+
} from "./config.ts";
|
|
47
74
|
import {
|
|
48
75
|
formatDuration,
|
|
49
76
|
frameBodyLines,
|
|
@@ -282,6 +309,24 @@ function termW(): number {
|
|
|
282
309
|
return Math.max(1, Math.min(raw, 210));
|
|
283
310
|
}
|
|
284
311
|
|
|
312
|
+
/**
|
|
313
|
+
* 1-based line number of the first character of `snippet` inside
|
|
314
|
+
* `content`. Used by the `edit` tool wrapper to shift per-edit diff
|
|
315
|
+
* line numbers so the rendered gutter matches the file (instead of
|
|
316
|
+
* starting at 1 for every edit). Returns 1 when content is empty or
|
|
317
|
+
* the snippet isn't found — best-effort fallback.
|
|
318
|
+
*/
|
|
319
|
+
function findLineOffset(content: string | null, snippet: string): number {
|
|
320
|
+
if (!content || !snippet) return 1;
|
|
321
|
+
const idx = content.indexOf(snippet);
|
|
322
|
+
if (idx < 0) return 1;
|
|
323
|
+
let line = 1;
|
|
324
|
+
for (let i = 0; i < idx; i++) {
|
|
325
|
+
if (content.charCodeAt(i) === 10) line += 1;
|
|
326
|
+
}
|
|
327
|
+
return line;
|
|
328
|
+
}
|
|
329
|
+
|
|
285
330
|
function shortPath(cwd: string, home: string, p: string): string {
|
|
286
331
|
if (!p) return "";
|
|
287
332
|
const r = relative(cwd, p);
|
|
@@ -918,6 +963,10 @@ type PiFaceliftSdk = {
|
|
|
918
963
|
createFindTool?: ToolFactory<FindToolInput>;
|
|
919
964
|
createGrepToolDefinition?: ToolFactory<GrepToolInput>;
|
|
920
965
|
createGrepTool?: ToolFactory<GrepToolInput>;
|
|
966
|
+
createWriteToolDefinition?: ToolFactory<WriteToolInput>;
|
|
967
|
+
createWriteTool?: ToolFactory<WriteToolInput>;
|
|
968
|
+
createEditToolDefinition?: ToolFactory<EditToolInput, EditToolDetails>;
|
|
969
|
+
createEditTool?: ToolFactory<EditToolInput, EditToolDetails>;
|
|
921
970
|
getAgentDir?: () => string;
|
|
922
971
|
};
|
|
923
972
|
type PiFaceliftApi = {
|
|
@@ -936,8 +985,12 @@ type BashParams = BashToolInput;
|
|
|
936
985
|
type LsParams = LsToolInput;
|
|
937
986
|
type FindParams = FindToolInput;
|
|
938
987
|
type GrepParams = GrepToolInput;
|
|
988
|
+
type WriteParams = WriteToolInput;
|
|
989
|
+
type EditParams = EditToolInput;
|
|
939
990
|
type ReadRenderState = { _rk?: string; _rt?: string };
|
|
940
991
|
type GrepRenderState = { _gk?: string; _gt?: string };
|
|
992
|
+
type WriteRenderState = { _wk?: string; _wt?: string };
|
|
993
|
+
type EditRenderState = { _ek?: string; _et?: string };
|
|
941
994
|
type BashRenderState = {
|
|
942
995
|
startedAt?: number;
|
|
943
996
|
endedAt?: number;
|
|
@@ -945,13 +998,27 @@ type BashRenderState = {
|
|
|
945
998
|
};
|
|
946
999
|
type FindResultDetails = { _type: "findResult"; text: string; pattern: string; matchCount: number };
|
|
947
1000
|
type GrepResultDetails = { _type: "grepResult"; text: string; pattern: string; matchCount: number };
|
|
1001
|
+
type WriteResultDetails =
|
|
1002
|
+
| { _type: "writeDiff"; filePath: string; diff: ParsedDiff; language: string | undefined }
|
|
1003
|
+
| { _type: "writeNew"; filePath: string; content: string; lineCount: number; language: string | undefined }
|
|
1004
|
+
| { _type: "writeNoChange"; filePath: string };
|
|
1005
|
+
type EditResultDetails = {
|
|
1006
|
+
_type: "editDiff";
|
|
1007
|
+
filePath: string;
|
|
1008
|
+
edits: Array<{ oldText: string; newText: string; diff: ParsedDiff }>;
|
|
1009
|
+
totalAdded: number;
|
|
1010
|
+
totalRemoved: number;
|
|
1011
|
+
language: string | undefined;
|
|
1012
|
+
};
|
|
948
1013
|
type RenderDetails =
|
|
949
1014
|
| { _type: "readImage"; filePath: string; data: string; mimeType: string }
|
|
950
1015
|
| { _type: "readFile"; filePath: string; content: string; offset: number; lineCount: number }
|
|
951
1016
|
| { _type: "bashResult"; text: string; exitCode: number | null; command: string }
|
|
952
1017
|
| { _type: "lsResult"; text: string; path: string; entryCount: number }
|
|
953
1018
|
| FindResultDetails
|
|
954
|
-
| GrepResultDetails
|
|
1019
|
+
| GrepResultDetails
|
|
1020
|
+
| WriteResultDetails
|
|
1021
|
+
| EditResultDetails;
|
|
955
1022
|
|
|
956
1023
|
function isTextContent(content: ToolContent): content is ToolTextContent {
|
|
957
1024
|
return content.type === "text";
|
|
@@ -993,6 +1060,8 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
993
1060
|
let createLsTool: ToolFactory<LsToolInput> | undefined;
|
|
994
1061
|
let createFindTool: ToolFactory<FindToolInput> | undefined;
|
|
995
1062
|
let createGrepTool: ToolFactory<GrepToolInput> | undefined;
|
|
1063
|
+
let createWriteTool: ToolFactory<WriteToolInput> | undefined;
|
|
1064
|
+
let createEditTool: ToolFactory<EditToolInput, EditToolDetails> | undefined;
|
|
996
1065
|
let TextComponent: TextComponentCtor;
|
|
997
1066
|
|
|
998
1067
|
let sdk: PiFaceliftSdk;
|
|
@@ -1005,6 +1074,8 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1005
1074
|
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
1006
1075
|
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
1007
1076
|
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
1077
|
+
createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
|
|
1078
|
+
createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
|
|
1008
1079
|
TextComponent = deps.TextComponent;
|
|
1009
1080
|
} else {
|
|
1010
1081
|
try {
|
|
@@ -1014,6 +1085,8 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1014
1085
|
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
1015
1086
|
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
1016
1087
|
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
1088
|
+
createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
|
|
1089
|
+
createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
|
|
1017
1090
|
TextComponent = require("@earendil-works/pi-tui").Text;
|
|
1018
1091
|
} catch {
|
|
1019
1092
|
return;
|
|
@@ -1036,6 +1109,23 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1036
1109
|
})(),
|
|
1037
1110
|
);
|
|
1038
1111
|
|
|
1112
|
+
// Pre-load diff palette (env vars + ~/.pi/settings.json overrides). Cheap
|
|
1113
|
+
// and idempotent — `resolveDiffColors` auto-derives bg tints from the
|
|
1114
|
+
// host theme later anyway.
|
|
1115
|
+
applyDiffPalette();
|
|
1116
|
+
|
|
1117
|
+
// Load (or seed) the per-package config at
|
|
1118
|
+
// `~/.pi/agent/wierd-facelift/config.json`. Held in a closure-local
|
|
1119
|
+
// `let` so the `/facelift` settings modal can mutate it without going
|
|
1120
|
+
// through a global. `decideDiffLayout` reads this object directly.
|
|
1121
|
+
let faceliftConfig: WierdFaceliftConfig = (() => {
|
|
1122
|
+
try {
|
|
1123
|
+
return loadFaceliftConfig();
|
|
1124
|
+
} catch {
|
|
1125
|
+
return faceliftEnvDefaults();
|
|
1126
|
+
}
|
|
1127
|
+
})();
|
|
1128
|
+
|
|
1039
1129
|
// ===================================================================
|
|
1040
1130
|
// read — syntax-highlighted file content
|
|
1041
1131
|
// ===================================================================
|
|
@@ -1572,4 +1662,550 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1572
1662
|
});
|
|
1573
1663
|
}
|
|
1574
1664
|
|
|
1665
|
+
// ===================================================================
|
|
1666
|
+
// Shared layout decision — honors the `diffLayout` knob in the
|
|
1667
|
+
// facelift config (~/.pi/agent/wierd-facelift/config.json), settable
|
|
1668
|
+
// via the `/facelift` slash command.
|
|
1669
|
+
//
|
|
1670
|
+
// In "consistent" mode (the default) a single tool call decides one
|
|
1671
|
+
// layout for every diff it shows: split iff every diff fits without
|
|
1672
|
+
// excessive wrapping, otherwise unified for all. This stops the
|
|
1673
|
+
// `Edit 1/2 = split, Edit 2/2 = unified` visual mix you'd otherwise
|
|
1674
|
+
// get when one edit happens to contain very long lines.
|
|
1675
|
+
// ===================================================================
|
|
1676
|
+
const decideDiffLayout = (
|
|
1677
|
+
diffs: ParsedDiff[],
|
|
1678
|
+
innerW: number,
|
|
1679
|
+
maxLines: number,
|
|
1680
|
+
): DiffLayout | undefined => {
|
|
1681
|
+
const pref = faceliftConfig.diffLayout;
|
|
1682
|
+
if (pref === "split") return "split";
|
|
1683
|
+
if (pref === "unified") return "unified";
|
|
1684
|
+
if (pref === "per-edit") return undefined;
|
|
1685
|
+
// "consistent" (default)
|
|
1686
|
+
if (diffs.length === 0) return undefined;
|
|
1687
|
+
const allFit = diffs.every((d) => canRenderSplit(d, innerW, maxLines));
|
|
1688
|
+
return allFit ? "split" : "unified";
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
// ===================================================================
|
|
1692
|
+
// /facelift slash command — opens the settings overlay (matches the
|
|
1693
|
+
// /voice and /web conventions). Bare `/facelift` opens the modal;
|
|
1694
|
+
// `/facelift status` prints the current config + path; `/facelift
|
|
1695
|
+
// reset` restores defaults.
|
|
1696
|
+
// ===================================================================
|
|
1697
|
+
const persistFaceliftConfig = (extCtx: ExtensionContext): void => {
|
|
1698
|
+
try {
|
|
1699
|
+
saveFaceliftConfig(faceliftConfig);
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1702
|
+
extCtx.ui.notify(
|
|
1703
|
+
`pi-facelift: failed to save config to ${getFaceliftConfigPath()}: ${msg}`,
|
|
1704
|
+
"error",
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
|
|
1709
|
+
const diffLayoutLabels: Record<DiffLayoutPreference, string> = {
|
|
1710
|
+
consistent: "consistent (same layout for every edit in a call)",
|
|
1711
|
+
split: "split (always side-by-side)",
|
|
1712
|
+
unified: "unified (always stacked)",
|
|
1713
|
+
"per-edit": "per-edit (each diff decides independently)",
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
const openFaceliftSettings = async (extCtx: ExtensionContext): Promise<void> => {
|
|
1717
|
+
const fields: Field[] = [
|
|
1718
|
+
{
|
|
1719
|
+
key: "diffLayout",
|
|
1720
|
+
type: "enum",
|
|
1721
|
+
label: "Diff layout",
|
|
1722
|
+
description:
|
|
1723
|
+
"How to lay out write/edit diffs. `consistent` keeps every edit in one tool call on the same layout (split when all fit, unified when any would wrap). The other modes force a specific layout or let each edit decide.",
|
|
1724
|
+
value: faceliftConfig.diffLayout,
|
|
1725
|
+
options: VALID_DIFF_LAYOUTS,
|
|
1726
|
+
optionLabels: diffLayoutLabels,
|
|
1727
|
+
},
|
|
1728
|
+
];
|
|
1729
|
+
await openSettingsModal(extCtx, {
|
|
1730
|
+
title: "@wierdbytes/pi-facelift",
|
|
1731
|
+
fields,
|
|
1732
|
+
onChange: (key, value) => {
|
|
1733
|
+
if (key === "diffLayout") {
|
|
1734
|
+
faceliftConfig = { ...faceliftConfig, diffLayout: value as DiffLayoutPreference };
|
|
1735
|
+
persistFaceliftConfig(extCtx);
|
|
1736
|
+
}
|
|
1737
|
+
},
|
|
1738
|
+
});
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1741
|
+
const showFaceliftStatus = (extCtx: ExtensionContext): void => {
|
|
1742
|
+
const lines = [
|
|
1743
|
+
`config: ${getFaceliftConfigPath()}`,
|
|
1744
|
+
`diffLayout: ${faceliftConfig.diffLayout}`,
|
|
1745
|
+
];
|
|
1746
|
+
extCtx.ui.notify(`@wierdbytes/pi-facelift\n${lines.join("\n")}`, "info");
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
const resetFaceliftConfig = (extCtx: ExtensionContext): void => {
|
|
1750
|
+
faceliftConfig = faceliftEnvDefaults();
|
|
1751
|
+
persistFaceliftConfig(extCtx);
|
|
1752
|
+
extCtx.ui.notify("pi-facelift: config reset to defaults.", "info");
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
pi.registerCommand("facelift", {
|
|
1756
|
+
description:
|
|
1757
|
+
"Open the @wierdbytes/pi-facelift settings overlay (no args). Subcommands: status | reset",
|
|
1758
|
+
handler: async (args: string, extCtx: ExtensionCommandContext) => {
|
|
1759
|
+
const trimmed = (args ?? "").trim();
|
|
1760
|
+
if (!trimmed) {
|
|
1761
|
+
await openFaceliftSettings(extCtx);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const [sub] = trimmed.split(/\s+/, 1);
|
|
1765
|
+
if (sub === "status") {
|
|
1766
|
+
showFaceliftStatus(extCtx);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (sub === "reset") {
|
|
1770
|
+
resetFaceliftConfig(extCtx);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
extCtx.ui.notify(
|
|
1774
|
+
"Usage: /facelift [status|reset] (no args ⇒ open settings overlay)",
|
|
1775
|
+
"warning",
|
|
1776
|
+
);
|
|
1777
|
+
},
|
|
1778
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1779
|
+
const subs = ["status", "reset"];
|
|
1780
|
+
const lcPrefix = prefix.toLowerCase();
|
|
1781
|
+
return subs.filter((s) => s.startsWith(lcPrefix)).map((s) => ({ value: s, label: s }));
|
|
1782
|
+
},
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
// ===================================================================
|
|
1786
|
+
// Per-path mutation lock
|
|
1787
|
+
// -------------------------------------------------------------------
|
|
1788
|
+
// Both `write` and `edit` snapshot the file *before* delegating to the
|
|
1789
|
+
// built-in tool's execute(). When two mutations to the same path are
|
|
1790
|
+
// queued concurrently the built-in tool serializes the actual write,
|
|
1791
|
+
// but our pre-execute snapshots run in parallel — so the second call
|
|
1792
|
+
// would diff against the file state from *before* the first call, not
|
|
1793
|
+
// the state the first call left behind. Result: stale, misleading
|
|
1794
|
+
// diffs in the rendered output.
|
|
1795
|
+
//
|
|
1796
|
+
// We fix it here (not in the built-in tool) by chaining
|
|
1797
|
+
// snapshot→execute pairs per resolved path. The chain is purely
|
|
1798
|
+
// in-process and only covers mutations going through our wrapper;
|
|
1799
|
+
// external writers (other processes, the user) are still racy, but
|
|
1800
|
+
// that's the same correctness contract the built-in tool already has.
|
|
1801
|
+
// ===================================================================
|
|
1802
|
+
|
|
1803
|
+
const pathMutationTails = new Map<string, Promise<void>>();
|
|
1804
|
+
function serializePerPath<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
|
1805
|
+
if (!path) return fn();
|
|
1806
|
+
const key = (() => {
|
|
1807
|
+
try {
|
|
1808
|
+
return resolvePath(path);
|
|
1809
|
+
} catch {
|
|
1810
|
+
return path;
|
|
1811
|
+
}
|
|
1812
|
+
})();
|
|
1813
|
+
const prev = pathMutationTails.get(key) ?? Promise.resolve();
|
|
1814
|
+
// `prev.then(fn, fn)` — run fn after prev settles, regardless of
|
|
1815
|
+
// whether prev rejected. We must not let an earlier failure poison
|
|
1816
|
+
// later snapshots on the same path.
|
|
1817
|
+
const result = prev.then(fn, fn);
|
|
1818
|
+
const tail = result.then(
|
|
1819
|
+
() => undefined,
|
|
1820
|
+
() => undefined,
|
|
1821
|
+
);
|
|
1822
|
+
pathMutationTails.set(key, tail);
|
|
1823
|
+
tail.then(() => {
|
|
1824
|
+
// Drop the entry only if no newer caller has chained onto us;
|
|
1825
|
+
// otherwise we'd unblock callers waiting in the chain.
|
|
1826
|
+
if (pathMutationTails.get(key) === tail) pathMutationTails.delete(key);
|
|
1827
|
+
});
|
|
1828
|
+
return result;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// ===================================================================
|
|
1832
|
+
// write — split diff (or syntax-highlighted preview for new files)
|
|
1833
|
+
// ===================================================================
|
|
1834
|
+
|
|
1835
|
+
if (createWriteTool) {
|
|
1836
|
+
const origWrite = createWriteTool(cwd);
|
|
1837
|
+
|
|
1838
|
+
pi.registerTool({
|
|
1839
|
+
...origWrite,
|
|
1840
|
+
name: "write",
|
|
1841
|
+
renderShell: "self",
|
|
1842
|
+
|
|
1843
|
+
async execute(
|
|
1844
|
+
tid: string,
|
|
1845
|
+
params: WriteParams,
|
|
1846
|
+
sig: AbortSignal | undefined,
|
|
1847
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1848
|
+
ctx: ExtensionContext,
|
|
1849
|
+
) {
|
|
1850
|
+
const fp = params.path ?? "";
|
|
1851
|
+
// Snapshot + execute must run atomically per-path so concurrent
|
|
1852
|
+
// writes to the same file don't both diff against the same
|
|
1853
|
+
// stale pre-state.
|
|
1854
|
+
const { oldContent, result } = await serializePerPath(fp, async () => {
|
|
1855
|
+
let snapshot: string | null = null;
|
|
1856
|
+
try {
|
|
1857
|
+
if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
|
|
1858
|
+
} catch {
|
|
1859
|
+
snapshot = null;
|
|
1860
|
+
}
|
|
1861
|
+
const r = (await origWrite.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1862
|
+
return { oldContent: snapshot, result: r };
|
|
1863
|
+
});
|
|
1864
|
+
const newContent = params.content ?? "";
|
|
1865
|
+
const language = diffLang(fp);
|
|
1866
|
+
|
|
1867
|
+
if (oldContent === null) {
|
|
1868
|
+
const lineCount = newContent ? newContent.split("\n").length : 0;
|
|
1869
|
+
setResultDetails<WriteResultDetails>(result, {
|
|
1870
|
+
_type: "writeNew",
|
|
1871
|
+
filePath: fp,
|
|
1872
|
+
content: newContent,
|
|
1873
|
+
lineCount,
|
|
1874
|
+
language,
|
|
1875
|
+
});
|
|
1876
|
+
} else if (oldContent === newContent) {
|
|
1877
|
+
setResultDetails<WriteResultDetails>(result, { _type: "writeNoChange", filePath: fp });
|
|
1878
|
+
} else {
|
|
1879
|
+
const diff = parseDiff(oldContent, newContent);
|
|
1880
|
+
setResultDetails<WriteResultDetails>(result, {
|
|
1881
|
+
_type: "writeDiff",
|
|
1882
|
+
filePath: fp,
|
|
1883
|
+
diff,
|
|
1884
|
+
language,
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
return result;
|
|
1888
|
+
},
|
|
1889
|
+
|
|
1890
|
+
renderCall(args: WriteParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1891
|
+
resolveBaseBackground(theme);
|
|
1892
|
+
const fp = args.path ?? "";
|
|
1893
|
+
const isNew = !fp || !existsSync(fp);
|
|
1894
|
+
const label = isNew ? "create" : "write";
|
|
1895
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1896
|
+
const title = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
|
|
1897
|
+
text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
|
|
1898
|
+
return text;
|
|
1899
|
+
},
|
|
1900
|
+
|
|
1901
|
+
renderResult(
|
|
1902
|
+
result: ToolResultLike,
|
|
1903
|
+
_opt: ToolRenderResultOptions,
|
|
1904
|
+
theme: ThemeLike,
|
|
1905
|
+
ctx: RenderContextLike<WriteRenderState>,
|
|
1906
|
+
) {
|
|
1907
|
+
resolveBaseBackground(theme);
|
|
1908
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1909
|
+
const status = getFrameStatus(ctx);
|
|
1910
|
+
const t = asTheme(theme);
|
|
1911
|
+
const w = termW();
|
|
1912
|
+
|
|
1913
|
+
if (ctx.isError) {
|
|
1914
|
+
text.setText(renderToolError(getTextContent(result) || "Error", t, w));
|
|
1915
|
+
return text;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
const d = result.details as RenderDetails | undefined;
|
|
1919
|
+
const dc: DiffColors = resolveDiffColors(theme);
|
|
1920
|
+
|
|
1921
|
+
if (d?._type === "writeNoChange") {
|
|
1922
|
+
const lab = `${FG_DIM}✓ no changes${RST}`;
|
|
1923
|
+
text.setText(frameResultWithBottomLabel("", lab, status, t, w));
|
|
1924
|
+
return text;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
if (d?._type === "writeNew") {
|
|
1928
|
+
const lab = `${FG_GREEN}✓ new file${RST} ${FG_DIM}(${d.lineCount} lines)${RST}`;
|
|
1929
|
+
// Diff/preview output is always rendered in full — no compact
|
|
1930
|
+
// mode, no `… N more lines` footer — so `ctx.expanded` is
|
|
1931
|
+
// irrelevant to the rendered body and stays out of the key.
|
|
1932
|
+
const key = `writeNew:${themeCacheKey(theme)}:${d.filePath}:${d.lineCount}:${w}:${status}`;
|
|
1933
|
+
if (ctx.state._wk !== key) {
|
|
1934
|
+
ctx.state._wk = key;
|
|
1935
|
+
ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
|
|
1936
|
+
if (d.content) {
|
|
1937
|
+
hlDiffBlock(d.content, d.language)
|
|
1938
|
+
.then((hlLines: string[]) => {
|
|
1939
|
+
if (ctx.state._wk !== key) return;
|
|
1940
|
+
const body = hlLines.join("\n");
|
|
1941
|
+
ctx.state._wt = frameResultWithBottomLabel(body, lab, status, t, w);
|
|
1942
|
+
ctx.invalidate();
|
|
1943
|
+
})
|
|
1944
|
+
.catch((err: unknown) => {
|
|
1945
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1946
|
+
console.error(`pi-facelift: write render failed: ${msg}`);
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
text.setText(ctx.state._wt ?? frameResultWithBottomLabel("", lab, status, t, w));
|
|
1951
|
+
return text;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
if (d?._type === "writeDiff") {
|
|
1955
|
+
const lab = `${summarizeDiff(d.diff.added, d.diff.removed)} ${FG_DIM}(${d.diff.lines.length} diff lines)${RST}`;
|
|
1956
|
+
// Diff output is always rendered in full — no compact mode,
|
|
1957
|
+
// no `… N more lines` footer — so `ctx.expanded` is
|
|
1958
|
+
// irrelevant to the rendered body and stays out of the key.
|
|
1959
|
+
const key = `writeDiff:${themeCacheKey(theme)}:${d.filePath}:${d.diff.added}:${d.diff.removed}:${d.diff.lines.length}:${w}:${status}`;
|
|
1960
|
+
if (ctx.state._wk !== key) {
|
|
1961
|
+
ctx.state._wk = key;
|
|
1962
|
+
ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
|
|
1963
|
+
const maxLines = d.diff.lines.length;
|
|
1964
|
+
const innerW = Math.max(40, w - 1);
|
|
1965
|
+
const layout = decideDiffLayout([d.diff], innerW, maxLines);
|
|
1966
|
+
// Reserve 1 col for the outer frame rail.
|
|
1967
|
+
renderSplit(d.diff, d.language, maxLines, dc, innerW, { frameless: true, layout })
|
|
1968
|
+
.then((rendered: string) => {
|
|
1969
|
+
if (ctx.state._wk !== key) return;
|
|
1970
|
+
ctx.state._wt = frameResultWithBottomLabel(rendered, lab, status, t, w);
|
|
1971
|
+
ctx.invalidate();
|
|
1972
|
+
})
|
|
1973
|
+
.catch((err: unknown) => {
|
|
1974
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1975
|
+
console.error(`pi-facelift: write diff render failed: ${msg}`);
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
text.setText(ctx.state._wt ?? frameResultWithBottomLabel("", lab, status, t, w));
|
|
1979
|
+
return text;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const fallback = result.content?.[0];
|
|
1983
|
+
const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "written";
|
|
1984
|
+
text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
|
|
1985
|
+
return text;
|
|
1986
|
+
},
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// ===================================================================
|
|
1991
|
+
// edit — per-edit split diffs with shared summary
|
|
1992
|
+
// ===================================================================
|
|
1993
|
+
|
|
1994
|
+
if (createEditTool) {
|
|
1995
|
+
const origEdit = createEditTool(cwd);
|
|
1996
|
+
|
|
1997
|
+
const getEditOperations = (input: Partial<EditToolInput>): Array<{ oldText: string; newText: string }> => {
|
|
1998
|
+
if (Array.isArray(input?.edits)) {
|
|
1999
|
+
return input.edits
|
|
2000
|
+
.map((edit) => ({
|
|
2001
|
+
oldText:
|
|
2002
|
+
typeof (edit as { oldText?: unknown })?.oldText === "string"
|
|
2003
|
+
? ((edit as { oldText: string }).oldText)
|
|
2004
|
+
: typeof (edit as { old_text?: unknown })?.old_text === "string"
|
|
2005
|
+
? ((edit as { old_text: string }).old_text)
|
|
2006
|
+
: "",
|
|
2007
|
+
newText:
|
|
2008
|
+
typeof (edit as { newText?: unknown })?.newText === "string"
|
|
2009
|
+
? ((edit as { newText: string }).newText)
|
|
2010
|
+
: typeof (edit as { new_text?: unknown })?.new_text === "string"
|
|
2011
|
+
? ((edit as { new_text: string }).new_text)
|
|
2012
|
+
: "",
|
|
2013
|
+
}))
|
|
2014
|
+
.filter((edit) => edit.oldText && edit.oldText !== edit.newText);
|
|
2015
|
+
}
|
|
2016
|
+
const legacy = input as { oldText?: unknown; old_text?: unknown; newText?: unknown; new_text?: unknown };
|
|
2017
|
+
const oldText =
|
|
2018
|
+
typeof legacy.oldText === "string"
|
|
2019
|
+
? legacy.oldText
|
|
2020
|
+
: typeof legacy.old_text === "string"
|
|
2021
|
+
? legacy.old_text
|
|
2022
|
+
: "";
|
|
2023
|
+
const newText =
|
|
2024
|
+
typeof legacy.newText === "string"
|
|
2025
|
+
? legacy.newText
|
|
2026
|
+
: typeof legacy.new_text === "string"
|
|
2027
|
+
? legacy.new_text
|
|
2028
|
+
: "";
|
|
2029
|
+
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
2030
|
+
};
|
|
2031
|
+
|
|
2032
|
+
pi.registerTool({
|
|
2033
|
+
...origEdit,
|
|
2034
|
+
name: "edit",
|
|
2035
|
+
renderShell: "self",
|
|
2036
|
+
|
|
2037
|
+
async execute(
|
|
2038
|
+
tid: string,
|
|
2039
|
+
params: EditParams,
|
|
2040
|
+
sig: AbortSignal | undefined,
|
|
2041
|
+
upd: AgentToolUpdateCallback<EditToolDetails | undefined> | undefined,
|
|
2042
|
+
ctx: ExtensionContext,
|
|
2043
|
+
) {
|
|
2044
|
+
const fp = params.path ?? "";
|
|
2045
|
+
// Snapshot the file BEFORE the edit so each edit's diff knows
|
|
2046
|
+
// where its `oldText` actually lives in the file. Without this,
|
|
2047
|
+
// `parseDiff(op.oldText, op.newText)` numbers every diff from
|
|
2048
|
+
// line 1 and the rendered gutter ends up off by the line
|
|
2049
|
+
// offset of the edit inside the file.
|
|
2050
|
+
//
|
|
2051
|
+
// Pair the snapshot with the underlying execute() inside a
|
|
2052
|
+
// per-path lock so concurrent edits to the same file don't
|
|
2053
|
+
// both snapshot the same stale pre-state and render diffs
|
|
2054
|
+
// against the wrong baseline.
|
|
2055
|
+
const { preEditFile, result } = await serializePerPath(fp, async () => {
|
|
2056
|
+
let snapshot: string | null = null;
|
|
2057
|
+
try {
|
|
2058
|
+
if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
|
|
2059
|
+
} catch {
|
|
2060
|
+
snapshot = null;
|
|
2061
|
+
}
|
|
2062
|
+
const r = (await origEdit.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
2063
|
+
return { preEditFile: snapshot, result: r };
|
|
2064
|
+
});
|
|
2065
|
+
const ops = getEditOperations(params);
|
|
2066
|
+
if (ops.length === 0) return result;
|
|
2067
|
+
|
|
2068
|
+
const language = diffLang(fp);
|
|
2069
|
+
// `cumulativeDelta` tracks the net line shift introduced by
|
|
2070
|
+
// earlier edits in this same tool call. The model conventionally
|
|
2071
|
+
// emits edits in file order so this is correct for the common
|
|
2072
|
+
// case; out-of-order edits at most produce slightly off new-side
|
|
2073
|
+
// numbers (old-side numbers stay accurate since we always
|
|
2074
|
+
// resolve against the pre-edit snapshot).
|
|
2075
|
+
let cumulativeDelta = 0;
|
|
2076
|
+
const edits = ops.map((op) => {
|
|
2077
|
+
const diff = parseDiff(op.oldText, op.newText);
|
|
2078
|
+
const oldOffset = findLineOffset(preEditFile, op.oldText);
|
|
2079
|
+
const newOffset = oldOffset + cumulativeDelta;
|
|
2080
|
+
if (oldOffset !== 1 || cumulativeDelta !== 0) {
|
|
2081
|
+
for (const line of diff.lines) {
|
|
2082
|
+
if (line.oldNum !== null) line.oldNum += oldOffset - 1;
|
|
2083
|
+
if (line.newNum !== null) line.newNum += newOffset - 1;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
cumulativeDelta += diff.added - diff.removed;
|
|
2087
|
+
return { oldText: op.oldText, newText: op.newText, diff };
|
|
2088
|
+
});
|
|
2089
|
+
const totalAdded = edits.reduce((acc, e) => acc + e.diff.added, 0);
|
|
2090
|
+
const totalRemoved = edits.reduce((acc, e) => acc + e.diff.removed, 0);
|
|
2091
|
+
|
|
2092
|
+
setResultDetails<EditResultDetails>(result, {
|
|
2093
|
+
_type: "editDiff",
|
|
2094
|
+
filePath: fp,
|
|
2095
|
+
edits,
|
|
2096
|
+
totalAdded,
|
|
2097
|
+
totalRemoved,
|
|
2098
|
+
language,
|
|
2099
|
+
});
|
|
2100
|
+
return result;
|
|
2101
|
+
},
|
|
2102
|
+
|
|
2103
|
+
renderCall(args: EditParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
2104
|
+
resolveBaseBackground(theme);
|
|
2105
|
+
const fp = args.path ?? "";
|
|
2106
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
2107
|
+
const title = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
2108
|
+
text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
|
|
2109
|
+
return text;
|
|
2110
|
+
},
|
|
2111
|
+
|
|
2112
|
+
renderResult(
|
|
2113
|
+
result: ToolResultLike,
|
|
2114
|
+
_opt: ToolRenderResultOptions,
|
|
2115
|
+
theme: ThemeLike,
|
|
2116
|
+
ctx: RenderContextLike<EditRenderState>,
|
|
2117
|
+
) {
|
|
2118
|
+
resolveBaseBackground(theme);
|
|
2119
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
2120
|
+
const status = getFrameStatus(ctx);
|
|
2121
|
+
const t = asTheme(theme);
|
|
2122
|
+
const w = termW();
|
|
2123
|
+
|
|
2124
|
+
if (ctx.isError) {
|
|
2125
|
+
text.setText(renderToolError(getTextContent(result) || "Error", t, w));
|
|
2126
|
+
return text;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
const d = result.details as RenderDetails | undefined;
|
|
2130
|
+
if (d?._type !== "editDiff") {
|
|
2131
|
+
const fallback = result.content?.[0];
|
|
2132
|
+
const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "edited";
|
|
2133
|
+
text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
|
|
2134
|
+
return text;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
const dc: DiffColors = resolveDiffColors(theme);
|
|
2138
|
+
const editCount = d.edits.length;
|
|
2139
|
+
const diffLineTotal = d.edits.reduce((acc, e) => acc + e.diff.lines.length, 0);
|
|
2140
|
+
const summary = summarizeDiff(d.totalAdded, d.totalRemoved);
|
|
2141
|
+
const editsLabel =
|
|
2142
|
+
editCount === 1 ? `1 edit ${summary}` : `${editCount} edits ${summary}`;
|
|
2143
|
+
const lab = `${editsLabel} ${FG_DIM}(${diffLineTotal} diff lines)${RST}`;
|
|
2144
|
+
|
|
2145
|
+
// Every edit block is rendered in full — no compact mode,
|
|
2146
|
+
// no `… N more edit blocks` footer, no per-edit line cap —
|
|
2147
|
+
// so `ctx.expanded` doesn't affect the body and stays out of
|
|
2148
|
+
// the key.
|
|
2149
|
+
const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${diffLineTotal}:${w}:${status}`;
|
|
2150
|
+
if (ctx.state._ek !== key) {
|
|
2151
|
+
ctx.state._ek = key;
|
|
2152
|
+
ctx.state._et = frameResultWithBottomLabel("", lab, status, t, w);
|
|
2153
|
+
|
|
2154
|
+
const innerW = Math.max(40, w - 1);
|
|
2155
|
+
|
|
2156
|
+
// Pick one layout for every edit in this call ("consistent" mode,
|
|
2157
|
+
// the default) so we never end up with `Edit 1 split, Edit 2
|
|
2158
|
+
// unified`. The user can override via .pi/settings.json's
|
|
2159
|
+
// `diffLayout` ("split"/"unified"/"per-edit"). We probe layout
|
|
2160
|
+
// with the longest edit so a single tall diff doesn't force
|
|
2161
|
+
// every other edit into unified mode unnecessarily.
|
|
2162
|
+
const probeCap = Math.max(
|
|
2163
|
+
1,
|
|
2164
|
+
...d.edits.map((e) => e.diff.lines.length),
|
|
2165
|
+
);
|
|
2166
|
+
const layout = decideDiffLayout(
|
|
2167
|
+
d.edits.map((e) => e.diff),
|
|
2168
|
+
innerW,
|
|
2169
|
+
probeCap,
|
|
2170
|
+
);
|
|
2171
|
+
|
|
2172
|
+
Promise.all(
|
|
2173
|
+
d.edits.map((edit, idx) =>
|
|
2174
|
+
renderSplit(
|
|
2175
|
+
edit.diff,
|
|
2176
|
+
d.language,
|
|
2177
|
+
edit.diff.lines.length,
|
|
2178
|
+
dc,
|
|
2179
|
+
innerW,
|
|
2180
|
+
{ frameless: true, layout },
|
|
2181
|
+
)
|
|
2182
|
+
.then((rendered) =>
|
|
2183
|
+
editCount > 1
|
|
2184
|
+
? `${FG_DIM}Edit ${idx + 1}/${editCount}${RST} ${summarizeDiff(edit.diff.added, edit.diff.removed)}\n${rendered}`
|
|
2185
|
+
: rendered,
|
|
2186
|
+
)
|
|
2187
|
+
.catch(() =>
|
|
2188
|
+
editCount > 1
|
|
2189
|
+
? `${FG_DIM}Edit ${idx + 1}/${editCount}${RST} ${summarizeDiff(edit.diff.added, edit.diff.removed)}`
|
|
2190
|
+
: summarizeDiff(edit.diff.added, edit.diff.removed),
|
|
2191
|
+
),
|
|
2192
|
+
),
|
|
2193
|
+
)
|
|
2194
|
+
.then((sections) => {
|
|
2195
|
+
if (ctx.state._ek !== key) return;
|
|
2196
|
+
ctx.state._et = frameResultWithBottomLabel(sections.join("\n\n"), lab, status, t, w);
|
|
2197
|
+
ctx.invalidate();
|
|
2198
|
+
})
|
|
2199
|
+
.catch((err: unknown) => {
|
|
2200
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2201
|
+
console.error(`pi-facelift: edit diff render failed: ${msg}`);
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
text.setText(ctx.state._et ?? frameResultWithBottomLabel("", lab, status, t, w));
|
|
2206
|
+
return text;
|
|
2207
|
+
},
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
|
|
1575
2211
|
}
|