@xynogen/pix-edit 0.2.17 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-edit",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "Pi tool — precise text replacement edit with diff rendering",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -13,13 +13,16 @@
13
13
  },
14
14
  "files": [
15
15
  "src",
16
- "!src/**/*.test.*",
17
16
  "README.md",
18
- "LICENSE"
17
+ "LICENSE",
18
+ "skills"
19
19
  ],
20
20
  "pi": {
21
21
  "extensions": [
22
22
  "src/extension.ts"
23
+ ],
24
+ "skills": [
25
+ "./skills"
23
26
  ]
24
27
  },
25
28
  "keywords": [
@@ -39,8 +42,8 @@
39
42
  "access": "public"
40
43
  },
41
44
  "dependencies": {
42
- "@xynogen/pix-pretty": "^2.0.0",
43
- "@xynogen/pix-runtime": "^0.19.0"
45
+ "@xynogen/pix-pretty": "^2.1.0",
46
+ "@xynogen/pix-runtime": "^0.20.0"
44
47
  },
45
48
  "peerDependencies": {
46
49
  "typebox": "*",
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: pix-edit
3
+ description: How to use `edit` without retries — hash anchors from a fresh `read`, one non-overlapping batch per file, and the exact-text fallback. Use before multi-hunk edits, after an edit failed or applied only partly, or when a file may have changed.
4
+ disable-model-invocation: true
5
+ ---
6
+ # edit — hash-anchor edits
7
+
8
+ `edit` was the second most error-prone tool in history (898 errors in 11,315 calls). Nearly all came from four causes below.
9
+
10
+ ## Formats
11
+
12
+ Hash anchors (preferred), from the `LINE#HASH` prefixes that `read` returns:
13
+
14
+ ```json
15
+ { "path": "src/a.ts", "edits": [
16
+ { "op": "replace", "pos": "12#A1F", "end": "14#C03", "lines": ["new line 12", "new line 13"] },
17
+ { "op": "insert_after", "pos": "40#9B2", "lines": ["added"] }
18
+ ] }
19
+ ```
20
+
21
+ - `op`: `replace`, `insert_before`, or `insert_after`. `end` is for `replace` only.
22
+ - `lines` holds raw source lines: no `LINE#HASH|` prefix and no newline characters. An empty array deletes.
23
+
24
+ Exact text (fallback only after a hash edit fails): `{ "oldText": "...", "newText": "..." }`. `oldText` must match exactly once.
25
+
26
+ ## Avoid the four common failures
27
+
28
+ | Error in history | Count | Fix |
29
+ |---|---|---|
30
+ | "Edit without read" | 293 | `read` the region in this session before editing it. |
31
+ | "target not found" / stale anchor | 77+ | The file changed. Use the "Current anchors" from the last edit result, or `read` again. Never guess a hash. |
32
+ | "PARTIAL APPLY" | 100+ | Edits in one call must not overlap and must all target the original file. Split dependent changes into separate calls. |
33
+ | schema: `edits.N` must be object / additional properties / `op` | 59 | Never mix hash and exact-text items in one call. Do not add fields such as `text` or `content`. |
34
+
35
+ ## Pattern
36
+
37
+ 1. `read` the lines you will change (use `offset`/`limit`, or `ast` `outline` then `symbol` for code).
38
+ 2. Put every independent change to that file in one `edit` call.
39
+ 3. Use the "Current anchors" in the result for the next edit to the same region.
40
+ 4. Read the "LSP after edit" note. Fix new errors before moving on.
41
+
42
+ ## Do not
43
+
44
+ - Use `sed -i`, `perl -pi`, or a heredoc in `bash` to change files.
45
+ - Use `write` to change a few lines of an existing file. `write` is for new files and complete rewrites.
46
+ - Retry the same failed edit unchanged. Read the error, then fix the anchor or the format.
package/src/edit.ts CHANGED
@@ -1,10 +1,3 @@
1
- import type {
2
- AgentToolUpdateCallback,
3
- EditToolInput,
4
- ExtensionContext,
5
- ToolRenderResultOptions,
6
- } from "@earendil-works/pi-coding-agent";
7
- import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
8
1
  import { MAX_RENDER_LINES } from "@xynogen/pix-pretty/config";
9
2
  import type { ToolContext } from "@xynogen/pix-pretty/context";
10
3
  import { parseDiff } from "@xynogen/pix-pretty/diff";
@@ -16,13 +9,12 @@ import {
16
9
  summarize,
17
10
  } from "@xynogen/pix-pretty/diff-render";
18
11
  import { lang } from "@xynogen/pix-pretty/lang";
12
+ import { toolCallRenderer, toolCardRenderer } from "@xynogen/pix-pretty/tool-card";
19
13
  import type {
20
14
  EditOperation,
21
15
  EditParams,
22
16
  EditRenderState,
23
17
  PiPrettyApi,
24
- RenderContextLike,
25
- ThemeLike,
26
18
  ToolFactory,
27
19
  ToolResultLike,
28
20
  } from "@xynogen/pix-pretty/types";
@@ -30,19 +22,12 @@ import {
30
22
  BODY_PAD,
31
23
  dotJoin,
32
24
  fillToolBackground,
33
- frameToolResult,
34
25
  getErrorMessage,
35
- getTextContent,
36
- hideCollapsedToolCall,
37
26
  isTextContent,
38
- renderCollapsed,
39
27
  renderTitle,
40
- renderToolError,
41
28
  setResultDetails,
42
29
  termW,
43
- unframeToolResult,
44
30
  } from "@xynogen/pix-pretty/utils";
45
- import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
46
31
  import {
47
32
  type EditInput,
48
33
  editPath,
@@ -51,6 +36,11 @@ import {
51
36
  type HashParams,
52
37
  prepareEditArguments,
53
38
  } from "@xynogen/pix-runtime/files";
39
+ import type {
40
+ AgentToolUpdateCallback,
41
+ EditToolInput,
42
+ ExtensionContext,
43
+ } from "@xynogen/pix-runtime/host";
54
44
 
55
45
  export {
56
46
  editPath,
@@ -126,7 +116,7 @@ export function registerEditTool(
126
116
  parameters: editSchema,
127
117
  promptSnippet: "Edit with hash anchors first; exact text only after failure",
128
118
  promptGuidelines: [
129
- "For edit, try hash anchors from read first. Only if the hash-anchor edit fails, fall back to oldText/newText. If the file changed, read the affected region again before using current source text; never guess anchors or overwrite unseen changes.",
119
+ 'For edit, try hash anchors from read first. Only if the hash-anchor edit fails, fall back to oldText/newText. If the file changed, read the affected region again before using current source text; never guess anchors or overwrite unseen changes. After a failed or partial edit, load read_skills(name="pix-edit", full=true).',
130
120
  ],
131
121
  prepareArguments: prepareEditArguments,
132
122
  constrainedSampling: { type: "json_schema", strict: "prefer" },
@@ -212,71 +202,39 @@ export function registerEditTool(
212
202
  return result;
213
203
  },
214
204
 
215
- renderCall(
216
- args: EditParams & Partial<HashParams>,
217
- theme: ThemeLike,
218
- renderCtx: RenderContextLike<EditRenderState>,
219
- ) {
220
- resolveBaseBackground(theme);
221
- const fp = args?.path ?? args?.file_path ?? "";
222
- const operations = getEditOperations(args);
223
- const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
224
- if (
225
- hideCollapsedToolCall(renderCtx.state as CollapseState, renderCtx.expanded, (value) =>
226
- text.setText(value),
227
- )
228
- )
229
- return text;
230
- const hdr = `${renderTitle(theme, "edit", renderCtx)} ${theme.fg("dim", sp(fp))}`;
231
-
232
- if (Array.isArray(args.edits) && args.edits.some((edit) => "pos" in edit)) {
233
- text.setText(
234
- fillToolBackground(dotJoin([hdr, theme.fg("muted", `${args.edits.length} operations`)])),
235
- );
236
- return text;
237
- }
238
- if (operations.length === 0) {
239
- text.setText(fillToolBackground(hdr));
240
- return text;
241
- }
205
+ renderCall: toolCallRenderer<EditParams & Partial<HashParams>, EditRenderState>(
206
+ TextComponent,
207
+ (args, theme, renderCtx) => {
208
+ const fp = args?.path ?? args?.file_path ?? "";
209
+ const operations = getEditOperations(args);
210
+ const hdr = `${renderTitle(theme, "edit", renderCtx)} ${theme.fg("dim", sp(fp))}`;
242
211
 
243
- const { summary } = summarizeEditOperations(operations);
244
- const coloredSummary = renderDiffSummary(summary, theme);
245
- const paint = (s: string) => theme.fg("muted", s);
246
- const suffix = dotJoin(
247
- [
248
- hdr,
249
- operations.length > 1 && theme.fg("muted", `${operations.length} edits`),
250
- theme.fg("muted", "estimate"),
251
- coloredSummary,
252
- ],
253
- paint,
254
- );
255
- text.setText(fillToolBackground(suffix));
256
- return text;
257
- },
212
+ if (Array.isArray(args.edits) && args.edits.some((edit) => "pos" in edit))
213
+ return fillToolBackground(
214
+ dotJoin([hdr, theme.fg("muted", `${args.edits.length} operations`)]),
215
+ );
216
+ if (operations.length === 0) return fillToolBackground(hdr);
258
217
 
259
- renderResult(
260
- result: ToolResultLike,
261
- _opt: ToolRenderResultOptions,
262
- theme: ThemeLike,
263
- renderCtx: RenderContextLike<EditRenderState>,
264
- ) {
265
- resolveBaseBackground(theme);
266
- const text = unframeToolResult(renderCtx.lastComponent ?? new TextComponent("", 0, 0));
267
- const d = result.details as Record<string, unknown> | undefined;
268
- const isPartial = _opt?.isPartial === true;
269
- const completed = () => frameToolResult(text, theme, renderCtx.isError);
270
- const structuredError =
271
- renderCtx.isError && (d?._type === "editInfo" || d?._type === "multiEditInfo");
272
- if (renderCtx.isError && (!structuredError || isPartial)) {
273
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
274
- return isPartial ? text : completed();
275
- }
218
+ const { summary } = summarizeEditOperations(operations);
219
+ const coloredSummary = renderDiffSummary(summary, theme);
220
+ const paint = (s: string) => theme.fg("muted", s);
221
+ const suffix = dotJoin(
222
+ [
223
+ hdr,
224
+ operations.length > 1 && theme.fg("muted", `${operations.length} edits`),
225
+ theme.fg("muted", "estimate"),
226
+ coloredSummary,
227
+ ],
228
+ paint,
229
+ );
230
+ return fillToolBackground(suffix);
231
+ },
232
+ ),
276
233
 
277
- // Auto-collapse: show summary line after delay
278
- const cs = renderCtx.state as CollapseState;
279
- if (!isPartial && tickCollapse("edit", cs, renderCtx.invalidate, renderCtx.expanded)) {
234
+ renderResult: toolCardRenderer<Record<string, unknown>, EditRenderState>(TextComponent, {
235
+ name: "edit",
236
+ owns: (d) => d?._type === "editInfo" || d?._type === "multiEditInfo",
237
+ collapsed(d) {
280
238
  const summary =
281
239
  d?._type === "editInfo"
282
240
  ? (d.summary as string)
@@ -289,98 +247,85 @@ export function registerEditTool(
289
247
  const ops = d.ops as Array<Record<string, unknown>> | undefined;
290
248
  filePath = String(ops?.[0]?.filePath ?? "");
291
249
  }
292
- text.setText(
293
- renderCollapsed(
294
- theme,
295
- "edit",
296
- sp(filePath),
297
- renderCtx.isError ? "failed" : summary,
298
- renderCtx.isError ? "error" : "success",
299
- ),
300
- );
301
- return text;
302
- }
303
-
304
- if (renderCtx.isError) {
305
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
306
- return completed();
307
- }
308
-
309
- // Single edit — full split diff
310
- if (d?._type === "editInfo") {
311
- const key = `ed:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${(d.oldContent as string).length}:${(d.newContent as string).length}:${d.language ?? ""}`;
312
- if (renderCtx.toolCallId) trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
313
- if (renderCtx.state._edk !== key) {
314
- renderCtx.state._edk = key;
315
- // ponytail: call already shows `edit <file> <summary>`; don't repeat summary+loc header — gutter carries absolute line
316
- renderCtx.state._edt = `${BODY_PAD}${theme.fg("muted", "rendering diff…")}`;
317
- const dc = resolveDiffColors(theme);
318
- const diff = parseDiff(
319
- d.oldContent as string,
320
- d.newContent as string,
321
- 3,
322
- d.editLine as number,
323
- );
324
- renderSplit(diff, d.language as string | undefined, MAX_RENDER_LINES, dc)
325
- .then((rendered) => {
326
- if (renderCtx.state._edk !== key) return;
327
- renderCtx.state._edt = rendered;
328
- renderCtx.invalidate();
329
- })
330
- .catch(() => {
331
- if (renderCtx.state._edk !== key) return;
332
- renderCtx.state._edt = `${BODY_PAD}${d.summary}`;
333
- renderCtx.invalidate();
334
- });
250
+ return { target: sp(filePath), summary };
251
+ },
252
+ body({ d, result, text, theme, ctx: renderCtx }) {
253
+ // Single edit — full split diff
254
+ if (d?._type === "editInfo") {
255
+ const key = `ed:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${(d.oldContent as string).length}:${(d.newContent as string).length}:${d.language ?? ""}`;
256
+ if (renderCtx.toolCallId) trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
257
+ if (renderCtx.state._edk !== key) {
258
+ renderCtx.state._edk = key;
259
+ // ponytail: call already shows `edit <file> <summary>`; don't repeat summary+loc header — gutter carries absolute line
260
+ renderCtx.state._edt = `${BODY_PAD}${theme.fg("muted", "rendering diff…")}`;
261
+ const dc = resolveDiffColors(theme);
262
+ const diff = parseDiff(
263
+ d.oldContent as string,
264
+ d.newContent as string,
265
+ 3,
266
+ d.editLine as number,
267
+ );
268
+ renderSplit(diff, d.language as string | undefined, MAX_RENDER_LINES, dc)
269
+ .then((rendered) => {
270
+ if (renderCtx.state._edk !== key) return;
271
+ renderCtx.state._edt = rendered;
272
+ renderCtx.invalidate();
273
+ })
274
+ .catch(() => {
275
+ if (renderCtx.state._edk !== key) return;
276
+ renderCtx.state._edt = `${BODY_PAD}${d.summary}`;
277
+ renderCtx.invalidate();
278
+ });
279
+ }
280
+ text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.summary}`);
281
+ return undefined;
335
282
  }
336
- text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.summary}`);
337
- return isPartial ? text : completed();
338
- }
339
283
 
340
- // Multi-edit — stacked diffs
341
- if (d?._type === "multiEditInfo") {
342
- const key = `med:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${d.editCount}:${d.diffLineCount}`;
343
- if (renderCtx.toolCallId) trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
344
- if (renderCtx.state._edk !== key) {
345
- renderCtx.state._edk = key;
346
- // ponytail: call already shows summary; render diffs directly
347
- renderCtx.state._edt = `${BODY_PAD}${theme.fg("muted", "rendering diff…")}`;
348
- const dc = resolveDiffColors(theme);
349
- Promise.all(
350
- (
351
- d.ops as Array<{
352
- oldContent: string;
353
- newContent: string;
354
- language?: string;
355
- editLine?: number;
356
- }>
357
- ).map((op) => {
358
- const diff = parseDiff(op.oldContent, op.newContent, 3, op.editLine ?? 0);
359
- return renderSplit(diff, op.language, MAX_RENDER_LINES, dc);
360
- }),
361
- )
362
- .then((rendered) => {
363
- if (renderCtx.state._edk !== key) return;
364
- const body = rendered.join(`\n${`${BODY_PAD}${theme.fg("muted", "···")}`}\n`);
365
- renderCtx.state._edt = body;
366
- renderCtx.invalidate();
367
- })
368
- .catch(() => {
369
- if (renderCtx.state._edk !== key) return;
370
- renderCtx.state._edt = `${BODY_PAD}${d.editCount} edits ${d.summary}`;
371
- renderCtx.invalidate();
372
- });
284
+ // Multi-edit — stacked diffs
285
+ if (d?._type === "multiEditInfo") {
286
+ const key = `med:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${d.editCount}:${d.diffLineCount}`;
287
+ if (renderCtx.toolCallId) trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
288
+ if (renderCtx.state._edk !== key) {
289
+ renderCtx.state._edk = key;
290
+ // ponytail: call already shows summary; render diffs directly
291
+ renderCtx.state._edt = `${BODY_PAD}${theme.fg("muted", "rendering diff…")}`;
292
+ const dc = resolveDiffColors(theme);
293
+ Promise.all(
294
+ (
295
+ d.ops as Array<{
296
+ oldContent: string;
297
+ newContent: string;
298
+ language?: string;
299
+ editLine?: number;
300
+ }>
301
+ ).map((op) => {
302
+ const diff = parseDiff(op.oldContent, op.newContent, 3, op.editLine ?? 0);
303
+ return renderSplit(diff, op.language, MAX_RENDER_LINES, dc);
304
+ }),
305
+ )
306
+ .then((rendered) => {
307
+ if (renderCtx.state._edk !== key) return;
308
+ const body = rendered.join(`\n${`${BODY_PAD}${theme.fg("muted", "···")}`}\n`);
309
+ renderCtx.state._edt = body;
310
+ renderCtx.invalidate();
311
+ })
312
+ .catch(() => {
313
+ if (renderCtx.state._edk !== key) return;
314
+ renderCtx.state._edt = `${BODY_PAD}${d.editCount} edits ${d.summary}`;
315
+ renderCtx.invalidate();
316
+ });
317
+ }
318
+ text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.editCount} edits ${d.summary}`);
319
+ return undefined;
373
320
  }
374
- text.setText(renderCtx.state._edt ?? `${BODY_PAD}${d.editCount} edits ${d.summary}`);
375
- return isPartial ? text : completed();
376
- }
377
321
 
378
- const fallback = result.content?.[0];
379
- const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "edited";
380
- text.setText(
381
- fillToolBackground(`${BODY_PAD}${theme.fg("dim", String(fallbackText).slice(0, 120))}`),
382
- );
383
- return isPartial ? text : completed();
384
- },
322
+ const fallback = result.content?.[0];
323
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "edited";
324
+ text.setText(
325
+ fillToolBackground(`${BODY_PAD}${theme.fg("dim", String(fallbackText).slice(0, 120))}`),
326
+ );
327
+ return undefined;
328
+ },
329
+ }),
385
330
  });
386
331
  }
package/src/extension.ts CHANGED
@@ -1,25 +1,21 @@
1
- import {
2
- createEditToolDefinition,
3
- createEditTool as createEditToolFallback,
4
- type EditToolInput,
5
- type ExtensionAPI,
6
- } from "@earendil-works/pi-coding-agent";
7
- import { Text } from "@earendil-works/pi-tui";
8
1
  import { CursorStore, fffState } from "@xynogen/pix-pretty/fff";
9
2
  import { attachResizeListener, trackInvalidator } from "@xynogen/pix-pretty/resize";
10
- import type { PiPrettyApi, ToolFactory } from "@xynogen/pix-pretty/types";
3
+ import { Text } from "@xynogen/pix-pretty/tui";
11
4
  import { linkedShortPath, viewportTextConstructor } from "@xynogen/pix-pretty/utils";
12
5
  import { initHashline } from "@xynogen/pix-runtime/hashline";
6
+ import {
7
+ createEditToolDefinition,
8
+ createEditTool as createEditToolFallback,
9
+ type ExtensionAPI,
10
+ } from "@xynogen/pix-runtime/host";
13
11
  import { once } from "@xynogen/pix-runtime/once";
14
12
  import { homeDir } from "@xynogen/pix-runtime/paths";
15
13
  import { registerEditTool } from "./edit.ts";
16
14
 
17
15
  export default async function pixEditExtension(pi: ExtensionAPI): Promise<void> {
18
16
  await initHashline();
19
- const prettyPi = pi as unknown as PiPrettyApi;
20
17
  once(pi, "pix-edit", () => {
21
- const createEditTool = (createEditToolDefinition ??
22
- createEditToolFallback) as unknown as ToolFactory<EditToolInput>;
18
+ const createEditTool = createEditToolDefinition ?? createEditToolFallback;
23
19
  if (!createEditTool) return;
24
20
 
25
21
  const cwd = process.cwd();
@@ -28,7 +24,7 @@ export default async function pixEditExtension(pi: ExtensionAPI): Promise<void>
28
24
  attachResizeListener();
29
25
 
30
26
  registerEditTool(
31
- prettyPi,
27
+ pi,
32
28
  createEditTool,
33
29
  {
34
30
  cwd,