@wierdbytes/pi-facelift 0.3.2 → 0.4.0

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/index.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import * as childProcess from "node:child_process";
27
- import { readFileSync } from "node:fs";
27
+ import { existsSync, readFileSync } from "node:fs";
28
28
  import { basename, dirname, extname, join, relative } from "node:path";
29
29
 
30
30
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
@@ -32,6 +32,8 @@ 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,491 @@ 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
+ // write — split diff (or syntax-highlighted preview for new files)
1787
+ // ===================================================================
1788
+
1789
+ if (createWriteTool) {
1790
+ const origWrite = createWriteTool(cwd);
1791
+
1792
+ pi.registerTool({
1793
+ ...origWrite,
1794
+ name: "write",
1795
+ renderShell: "self",
1796
+
1797
+ async execute(
1798
+ tid: string,
1799
+ params: WriteParams,
1800
+ sig: AbortSignal | undefined,
1801
+ upd: AgentToolUpdateCallback<unknown> | undefined,
1802
+ ctx: ExtensionContext,
1803
+ ) {
1804
+ const fp = params.path ?? "";
1805
+ let oldContent: string | null = null;
1806
+ try {
1807
+ if (fp && existsSync(fp)) oldContent = readFileSync(fp, "utf-8");
1808
+ } catch {
1809
+ oldContent = null;
1810
+ }
1811
+
1812
+ const result = (await origWrite.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1813
+ const newContent = params.content ?? "";
1814
+ const language = diffLang(fp);
1815
+
1816
+ if (oldContent === null) {
1817
+ const lineCount = newContent ? newContent.split("\n").length : 0;
1818
+ setResultDetails<WriteResultDetails>(result, {
1819
+ _type: "writeNew",
1820
+ filePath: fp,
1821
+ content: newContent,
1822
+ lineCount,
1823
+ language,
1824
+ });
1825
+ } else if (oldContent === newContent) {
1826
+ setResultDetails<WriteResultDetails>(result, { _type: "writeNoChange", filePath: fp });
1827
+ } else {
1828
+ const diff = parseDiff(oldContent, newContent);
1829
+ setResultDetails<WriteResultDetails>(result, {
1830
+ _type: "writeDiff",
1831
+ filePath: fp,
1832
+ diff,
1833
+ language,
1834
+ });
1835
+ }
1836
+ return result;
1837
+ },
1838
+
1839
+ renderCall(args: WriteParams, theme: ThemeLike, ctx: RenderContextLike) {
1840
+ resolveBaseBackground(theme);
1841
+ const fp = args.path ?? "";
1842
+ const isNew = !fp || !existsSync(fp);
1843
+ const label = isNew ? "create" : "write";
1844
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1845
+ const title = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
1846
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1847
+ return text;
1848
+ },
1849
+
1850
+ renderResult(
1851
+ result: ToolResultLike,
1852
+ _opt: ToolRenderResultOptions,
1853
+ theme: ThemeLike,
1854
+ ctx: RenderContextLike<WriteRenderState>,
1855
+ ) {
1856
+ resolveBaseBackground(theme);
1857
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1858
+ const status = getFrameStatus(ctx);
1859
+ const t = asTheme(theme);
1860
+ const w = termW();
1861
+
1862
+ if (ctx.isError) {
1863
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1864
+ return text;
1865
+ }
1866
+
1867
+ const d = result.details as RenderDetails | undefined;
1868
+ const dc: DiffColors = resolveDiffColors(theme);
1869
+
1870
+ if (d?._type === "writeNoChange") {
1871
+ const lab = `${FG_DIM}✓ no changes${RST}`;
1872
+ text.setText(frameResultWithBottomLabel("", lab, status, t, w));
1873
+ return text;
1874
+ }
1875
+
1876
+ if (d?._type === "writeNew") {
1877
+ const lab = `${FG_GREEN}✓ new file${RST} ${FG_DIM}(${d.lineCount} lines)${RST}`;
1878
+ const key = `writeNew:${themeCacheKey(theme)}:${d.filePath}:${d.lineCount}:${w}:${status}`;
1879
+ if (ctx.state._wk !== key) {
1880
+ ctx.state._wk = key;
1881
+ ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
1882
+ if (d.content) {
1883
+ hlDiffBlock(d.content, d.language)
1884
+ .then((hlLines: string[]) => {
1885
+ if (ctx.state._wk !== key) return;
1886
+ const maxShow = ctx.expanded ? hlLines.length : MAX_PREVIEW_LINES;
1887
+ const preview = hlLines.slice(0, maxShow).join("\n");
1888
+ const rem = hlLines.length - maxShow;
1889
+ const body =
1890
+ rem > 0 ? `${preview}\n${FG_DIM}… ${rem} more lines${RST}` : preview;
1891
+ ctx.state._wt = frameResultWithBottomLabel(body, lab, status, t, w);
1892
+ ctx.invalidate();
1893
+ })
1894
+ .catch((err: unknown) => {
1895
+ const msg = err instanceof Error ? err.message : String(err);
1896
+ console.error(`pi-facelift: write render failed: ${msg}`);
1897
+ });
1898
+ }
1899
+ }
1900
+ text.setText(ctx.state._wt ?? frameResultWithBottomLabel("", lab, status, t, w));
1901
+ return text;
1902
+ }
1903
+
1904
+ if (d?._type === "writeDiff") {
1905
+ const lab = `${summarizeDiff(d.diff.added, d.diff.removed)} ${FG_DIM}(${d.diff.lines.length} diff lines)${RST}`;
1906
+ const key = `writeDiff:${themeCacheKey(theme)}:${d.filePath}:${d.diff.added}:${d.diff.removed}:${d.diff.lines.length}:${w}:${status}`;
1907
+ if (ctx.state._wk !== key) {
1908
+ ctx.state._wk = key;
1909
+ ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
1910
+ const maxLines = ctx.expanded ? d.diff.lines.length : MAX_PREVIEW_LINES;
1911
+ const innerW = Math.max(40, w - 1);
1912
+ const layout = decideDiffLayout([d.diff], innerW, maxLines);
1913
+ // Reserve 1 col for the outer frame rail.
1914
+ renderSplit(d.diff, d.language, maxLines, dc, innerW, { frameless: true, layout })
1915
+ .then((rendered: string) => {
1916
+ if (ctx.state._wk !== key) return;
1917
+ ctx.state._wt = frameResultWithBottomLabel(rendered, lab, status, t, w);
1918
+ ctx.invalidate();
1919
+ })
1920
+ .catch((err: unknown) => {
1921
+ const msg = err instanceof Error ? err.message : String(err);
1922
+ console.error(`pi-facelift: write diff render failed: ${msg}`);
1923
+ });
1924
+ }
1925
+ text.setText(ctx.state._wt ?? frameResultWithBottomLabel("", lab, status, t, w));
1926
+ return text;
1927
+ }
1928
+
1929
+ const fallback = result.content?.[0];
1930
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "written";
1931
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1932
+ return text;
1933
+ },
1934
+ });
1935
+ }
1936
+
1937
+ // ===================================================================
1938
+ // edit — per-edit split diffs with shared summary
1939
+ // ===================================================================
1940
+
1941
+ if (createEditTool) {
1942
+ const origEdit = createEditTool(cwd);
1943
+
1944
+ const getEditOperations = (input: Partial<EditToolInput>): Array<{ oldText: string; newText: string }> => {
1945
+ if (Array.isArray(input?.edits)) {
1946
+ return input.edits
1947
+ .map((edit) => ({
1948
+ oldText:
1949
+ typeof (edit as { oldText?: unknown })?.oldText === "string"
1950
+ ? ((edit as { oldText: string }).oldText)
1951
+ : typeof (edit as { old_text?: unknown })?.old_text === "string"
1952
+ ? ((edit as { old_text: string }).old_text)
1953
+ : "",
1954
+ newText:
1955
+ typeof (edit as { newText?: unknown })?.newText === "string"
1956
+ ? ((edit as { newText: string }).newText)
1957
+ : typeof (edit as { new_text?: unknown })?.new_text === "string"
1958
+ ? ((edit as { new_text: string }).new_text)
1959
+ : "",
1960
+ }))
1961
+ .filter((edit) => edit.oldText && edit.oldText !== edit.newText);
1962
+ }
1963
+ const legacy = input as { oldText?: unknown; old_text?: unknown; newText?: unknown; new_text?: unknown };
1964
+ const oldText =
1965
+ typeof legacy.oldText === "string"
1966
+ ? legacy.oldText
1967
+ : typeof legacy.old_text === "string"
1968
+ ? legacy.old_text
1969
+ : "";
1970
+ const newText =
1971
+ typeof legacy.newText === "string"
1972
+ ? legacy.newText
1973
+ : typeof legacy.new_text === "string"
1974
+ ? legacy.new_text
1975
+ : "";
1976
+ return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1977
+ };
1978
+
1979
+ pi.registerTool({
1980
+ ...origEdit,
1981
+ name: "edit",
1982
+ renderShell: "self",
1983
+
1984
+ async execute(
1985
+ tid: string,
1986
+ params: EditParams,
1987
+ sig: AbortSignal | undefined,
1988
+ upd: AgentToolUpdateCallback<EditToolDetails | undefined> | undefined,
1989
+ ctx: ExtensionContext,
1990
+ ) {
1991
+ const fp = params.path ?? "";
1992
+ // Snapshot the file BEFORE the edit so each edit's diff knows
1993
+ // where its `oldText` actually lives in the file. Without this,
1994
+ // `parseDiff(op.oldText, op.newText)` numbers every diff from
1995
+ // line 1 and the rendered gutter ends up off by the line
1996
+ // offset of the edit inside the file.
1997
+ let preEditFile: string | null = null;
1998
+ try {
1999
+ if (fp && existsSync(fp)) preEditFile = readFileSync(fp, "utf-8");
2000
+ } catch {
2001
+ preEditFile = null;
2002
+ }
2003
+
2004
+ const result = (await origEdit.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
2005
+ const ops = getEditOperations(params);
2006
+ if (ops.length === 0) return result;
2007
+
2008
+ const language = diffLang(fp);
2009
+ // `cumulativeDelta` tracks the net line shift introduced by
2010
+ // earlier edits in this same tool call. The model conventionally
2011
+ // emits edits in file order so this is correct for the common
2012
+ // case; out-of-order edits at most produce slightly off new-side
2013
+ // numbers (old-side numbers stay accurate since we always
2014
+ // resolve against the pre-edit snapshot).
2015
+ let cumulativeDelta = 0;
2016
+ const edits = ops.map((op) => {
2017
+ const diff = parseDiff(op.oldText, op.newText);
2018
+ const oldOffset = findLineOffset(preEditFile, op.oldText);
2019
+ const newOffset = oldOffset + cumulativeDelta;
2020
+ if (oldOffset !== 1 || cumulativeDelta !== 0) {
2021
+ for (const line of diff.lines) {
2022
+ if (line.oldNum !== null) line.oldNum += oldOffset - 1;
2023
+ if (line.newNum !== null) line.newNum += newOffset - 1;
2024
+ }
2025
+ }
2026
+ cumulativeDelta += diff.added - diff.removed;
2027
+ return { oldText: op.oldText, newText: op.newText, diff };
2028
+ });
2029
+ const totalAdded = edits.reduce((acc, e) => acc + e.diff.added, 0);
2030
+ const totalRemoved = edits.reduce((acc, e) => acc + e.diff.removed, 0);
2031
+
2032
+ setResultDetails<EditResultDetails>(result, {
2033
+ _type: "editDiff",
2034
+ filePath: fp,
2035
+ edits,
2036
+ totalAdded,
2037
+ totalRemoved,
2038
+ language,
2039
+ });
2040
+ return result;
2041
+ },
2042
+
2043
+ renderCall(args: EditParams, theme: ThemeLike, ctx: RenderContextLike) {
2044
+ resolveBaseBackground(theme);
2045
+ const fp = args.path ?? "";
2046
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
2047
+ const title = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
2048
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
2049
+ return text;
2050
+ },
2051
+
2052
+ renderResult(
2053
+ result: ToolResultLike,
2054
+ _opt: ToolRenderResultOptions,
2055
+ theme: ThemeLike,
2056
+ ctx: RenderContextLike<EditRenderState>,
2057
+ ) {
2058
+ resolveBaseBackground(theme);
2059
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
2060
+ const status = getFrameStatus(ctx);
2061
+ const t = asTheme(theme);
2062
+ const w = termW();
2063
+
2064
+ if (ctx.isError) {
2065
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
2066
+ return text;
2067
+ }
2068
+
2069
+ const d = result.details as RenderDetails | undefined;
2070
+ if (d?._type !== "editDiff") {
2071
+ const fallback = result.content?.[0];
2072
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "edited";
2073
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
2074
+ return text;
2075
+ }
2076
+
2077
+ const dc: DiffColors = resolveDiffColors(theme);
2078
+ const editCount = d.edits.length;
2079
+ const diffLineTotal = d.edits.reduce((acc, e) => acc + e.diff.lines.length, 0);
2080
+ const summary = summarizeDiff(d.totalAdded, d.totalRemoved);
2081
+ const editsLabel =
2082
+ editCount === 1 ? `1 edit ${summary}` : `${editCount} edits ${summary}`;
2083
+ const lab = `${editsLabel} ${FG_DIM}(${diffLineTotal} diff lines)${RST}`;
2084
+
2085
+ const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${diffLineTotal}:${w}:${status}:${ctx.expanded ? 1 : 0}`;
2086
+ if (ctx.state._ek !== key) {
2087
+ ctx.state._ek = key;
2088
+ ctx.state._et = frameResultWithBottomLabel("", lab, status, t, w);
2089
+
2090
+ const innerW = Math.max(40, w - 1);
2091
+ const perEditCap = ctx.expanded
2092
+ ? Number.POSITIVE_INFINITY
2093
+ : Math.max(8, Math.floor(MAX_PREVIEW_LINES / Math.max(1, Math.min(editCount, 3))));
2094
+ const shownEdits = ctx.expanded ? d.edits : d.edits.slice(0, 3);
2095
+ const remaining = d.edits.length - shownEdits.length;
2096
+
2097
+ // Pick one layout for every edit in this call ("consistent" mode,
2098
+ // the default) so we never end up with `Edit 1 split, Edit 2
2099
+ // unified`. The user can override via .pi/settings.json's
2100
+ // `diffLayout` ("split"/"unified"/"per-edit").
2101
+ const probeCap = Number.isFinite(perEditCap) ? perEditCap : MAX_PREVIEW_LINES;
2102
+ const layout = decideDiffLayout(
2103
+ shownEdits.map((e) => e.diff),
2104
+ innerW,
2105
+ probeCap,
2106
+ );
2107
+
2108
+ Promise.all(
2109
+ shownEdits.map((edit, idx) =>
2110
+ renderSplit(
2111
+ edit.diff,
2112
+ d.language,
2113
+ Number.isFinite(perEditCap) ? perEditCap : edit.diff.lines.length,
2114
+ dc,
2115
+ innerW,
2116
+ { frameless: true, layout },
2117
+ )
2118
+ .then((rendered) =>
2119
+ editCount > 1
2120
+ ? `${FG_DIM}Edit ${idx + 1}/${editCount}${RST} ${summarizeDiff(edit.diff.added, edit.diff.removed)}\n${rendered}`
2121
+ : rendered,
2122
+ )
2123
+ .catch(() =>
2124
+ editCount > 1
2125
+ ? `${FG_DIM}Edit ${idx + 1}/${editCount}${RST} ${summarizeDiff(edit.diff.added, edit.diff.removed)}`
2126
+ : summarizeDiff(edit.diff.added, edit.diff.removed),
2127
+ ),
2128
+ ),
2129
+ )
2130
+ .then((sections) => {
2131
+ if (ctx.state._ek !== key) return;
2132
+ const joined = sections.join("\n\n");
2133
+ const suffix =
2134
+ remaining > 0
2135
+ ? `\n\n${FG_DIM}… ${remaining} more edit block${remaining === 1 ? "" : "s"}${RST}`
2136
+ : "";
2137
+ ctx.state._et = frameResultWithBottomLabel(`${joined}${suffix}`, lab, status, t, w);
2138
+ ctx.invalidate();
2139
+ })
2140
+ .catch((err: unknown) => {
2141
+ const msg = err instanceof Error ? err.message : String(err);
2142
+ console.error(`pi-facelift: edit diff render failed: ${msg}`);
2143
+ });
2144
+ }
2145
+
2146
+ text.setText(ctx.state._et ?? frameResultWithBottomLabel("", lab, status, t, w));
2147
+ return text;
2148
+ },
2149
+ });
2150
+ }
2151
+
1575
2152
  }
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.0",
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",