@jerryan/pi-hashline-edit 0.7.3 → 0.8.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/src/undo.ts ADDED
@@ -0,0 +1,212 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { constants } from "fs";
6
+ import { readFileSync } from "fs";
7
+ import { access as fsAccess } from "fs/promises";
8
+ import {
9
+ detectLineEnding,
10
+ generateDiffString,
11
+ normalizeToLF,
12
+ restoreLineEndings,
13
+ stripBom,
14
+ } from "./edit-diff";
15
+ import { resolveMutationTargetPath, writeFileAtomically } from "./fs-write";
16
+ import { loadFileKindAndText } from "./file-kind";
17
+ import { resolveToCwd } from "./path-utils";
18
+ import { throwIfAborted } from "./runtime";
19
+ import { getFileSnapshot } from "./snapshot";
20
+ import { buildChangedResponse } from "./edit-response";
21
+ import { PACKAGE_INFO } from "./package-info";
22
+
23
+ const UNDO_DESC = readFileSync(
24
+ new URL("../tool-descriptions/undo.md", import.meta.url),
25
+ "utf-8",
26
+ ).trim();
27
+
28
+ /** Maximum number of turns after which undo becomes unavailable.
29
+ * Allows patterns like edit -> read -> undo, but prevents undoing
30
+ * edits from distant conversation history. */
31
+ const MAX_UNDO_TURNS = 3;
32
+
33
+ export type LastEdit = {
34
+ path: string;
35
+ previousContent: string;
36
+ turnIndex: number;
37
+ };
38
+
39
+ let lastEdit: LastEdit | undefined;
40
+ let currentTurnIndex = 0;
41
+
42
+ export function setCurrentTurn(index: number): void {
43
+ currentTurnIndex = index;
44
+ }
45
+
46
+ export function setLastEdit(entry: Omit<LastEdit, "turnIndex">): void {
47
+ lastEdit = { ...entry, turnIndex: currentTurnIndex };
48
+ }
49
+
50
+ export function getLastEdit(): LastEdit | undefined {
51
+ return lastEdit;
52
+ }
53
+
54
+ export function clearLastEdit(): void {
55
+ lastEdit = undefined;
56
+ }
57
+
58
+ const undoToolSchema = Type.Object({}, { additionalProperties: false });
59
+
60
+ type UndoToolDetails = {
61
+ diff: string;
62
+ snapshotId?: string;
63
+ package: { name: string; version: string };
64
+ };
65
+
66
+ function colorDiffLines(
67
+ lines: string[],
68
+ theme: { fg: (token: string, text: string) => string },
69
+ ): string[] {
70
+ return lines.map((line) => {
71
+ if (line.startsWith("+") && !line.startsWith("+++")) {
72
+ return theme.fg("success", line);
73
+ }
74
+ if (line.startsWith("-") && !line.startsWith("---")) {
75
+ return theme.fg("error", line);
76
+ }
77
+ return theme.fg("dim", line);
78
+ });
79
+ }
80
+
81
+ const undoToolDefinition: ToolDefinition<
82
+ typeof undoToolSchema,
83
+ UndoToolDetails
84
+ > & { renderShell?: "default" | "self" } = {
85
+ name: "undo",
86
+ label: "Undo",
87
+ description: UNDO_DESC,
88
+ parameters: undoToolSchema,
89
+ renderShell: "default",
90
+
91
+ renderCall(_args, theme, _context) {
92
+ return new Text(
93
+ `${theme.fg("toolTitle", "undo")} ${theme.fg("toolOutput", "revert last edit")}`,
94
+ 0,
95
+ 0,
96
+ );
97
+ },
98
+
99
+ renderResult(result, { isPartial }, theme, _context) {
100
+ if (isPartial) {
101
+ return new Text(theme.fg("warning", "Undoing..."), 0, 0);
102
+ }
103
+
104
+ const typedResult = result as {
105
+ content?: Array<{ type: string; text?: string }>;
106
+ details?: UndoToolDetails;
107
+ };
108
+
109
+ if (typedResult.details?.diff) {
110
+ const text = colorDiffLines(
111
+ typedResult.details.diff.split("\n"),
112
+ theme,
113
+ ).join("\n");
114
+ return new Text(text, 0, 0);
115
+ }
116
+
117
+ const renderedText = typedResult.content?.find(
118
+ (entry): entry is { type: "text"; text: string } =>
119
+ entry.type === "text" && typeof entry.text === "string",
120
+ )?.text;
121
+
122
+ if (renderedText) {
123
+ return new Text(theme.fg("error", renderedText), 0, 0);
124
+ }
125
+
126
+ return new Text("", 0, 0);
127
+ },
128
+
129
+ async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
130
+ const entry = lastEdit;
131
+ if (!entry) {
132
+ throw new Error(
133
+ "[E_NO_UNDO] No edit to undo. The undo tool only reverts the most recent hashline edit in this session.",
134
+ );
135
+ }
136
+
137
+ const turnsSinceEdit = currentTurnIndex - entry.turnIndex;
138
+ if (turnsSinceEdit > MAX_UNDO_TURNS) {
139
+ throw new Error(
140
+ `[E_NO_UNDO] The last edit was ${turnsSinceEdit} turns ago. Undo is only available for edits within the last ${MAX_UNDO_TURNS} turns.`,
141
+ );
142
+ }
143
+
144
+ const path = entry.path;
145
+ const absolutePath = resolveToCwd(path, ctx.cwd);
146
+ const mutationTargetPath = await resolveMutationTargetPath(absolutePath);
147
+
148
+ return withFileMutationQueue(mutationTargetPath, async () => {
149
+ throwIfAborted(signal);
150
+ try {
151
+ await fsAccess(absolutePath, constants.R_OK | constants.W_OK);
152
+ } catch (error: unknown) {
153
+ const code = (error as NodeJS.ErrnoException).code;
154
+ if (code === "ENOENT") {
155
+ throw new Error(`File not found: ${path}`);
156
+ }
157
+ if (code === "EACCES" || code === "EPERM") {
158
+ throw new Error(`File is not writable: ${path}`);
159
+ }
160
+ throw new Error(`Cannot access file: ${path}`);
161
+ }
162
+
163
+ throwIfAborted(signal);
164
+ const file = await loadFileKindAndText(absolutePath);
165
+ if (file.kind !== "text") {
166
+ throw new Error(
167
+ `Hashline undo only supports text files. ${path} is ${file.kind}.`,
168
+ );
169
+ }
170
+
171
+ throwIfAborted(signal);
172
+ const { bom, text: currentText } = stripBom(file.text);
173
+ const originalEnding = detectLineEnding(currentText);
174
+ const currentNormalized = normalizeToLF(currentText);
175
+ const restoredContent = entry.previousContent;
176
+
177
+ if (currentNormalized === restoredContent) {
178
+ return {
179
+ content: [{ type: "text", text: "No changes needed. File already matches the pre-edit state." }],
180
+ details: {
181
+ diff: "",
182
+ package: PACKAGE_INFO,
183
+ },
184
+ };
185
+ }
186
+
187
+ throwIfAborted(signal);
188
+ await writeFileAtomically(
189
+ absolutePath,
190
+ bom + restoreLineEndings(restoredContent, originalEnding),
191
+ );
192
+
193
+ clearLastEdit();
194
+ const updatedSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
195
+ const { diff } = generateDiffString(currentNormalized, restoredContent);
196
+
197
+ return buildChangedResponse({
198
+ path,
199
+ originalNormalized: currentNormalized,
200
+ result: restoredContent,
201
+ warnings: [],
202
+ snapshotId: updatedSnapshotId,
203
+ editsAttempted: 1,
204
+ noopEditsCount: 0,
205
+ });
206
+ });
207
+ },
208
+ };
209
+
210
+ export function registerUndoTool(pi: ExtensionAPI): void {
211
+ pi.registerTool(undoToolDefinition);
212
+ }
@@ -1,23 +1,23 @@
1
- Patch a UTF-8 text file using `LINE#HASH` anchors copied verbatim from `read`.
2
-
3
- Submit one `edit` call per file. All operations go in a single `edits` array; anchors must come from the same fresh source — the most recent `read` or diff output of a successful `edit` on this file.
4
-
5
- Each edit entry replaces an inclusive anchor range:
6
- ```json
7
- { "range": [startAnchor, endAnchor], "lines": [...] }
8
- ```
9
- - `range` — `[start, end]` pair of LINE#HASH anchors from the most recent `read` or diff output.
10
- Use the same anchor twice for single-line: `["42#A4", "42#A4"]`.
11
- - `lines` — new content replacing the range (string array). Use `[]` to delete.
12
- Must be literal file content, not LINE#HASH│-prefixed output. Match indentation exactly.
13
-
14
- Example:
15
- ```json
16
- { "path": "src/main.ts", "edits": [
17
- { "range": ["12#3F", "12#3F"], "lines": ["const x = 1;"] },
18
- { "range": ["20#B2", "25#C7"], "lines": ["function foo() {", " return 42;", "}"] }
19
- ] }
20
-
21
- Rules:
22
- - Do not guess, shift, or construct anchors. Copy them from the most recent `read` or diff output of this file.
23
- - Do not emit overlapping or adjacent edits — merge them into one.
1
+ Patch a UTF-8 text file using `LINE#HASH` anchors copied verbatim from `read`.
2
+
3
+ Submit one `edit` call per file. All operations go in a single `edits` array; anchors must come from the same fresh source — the most recent `read` or diff output of a successful `edit` on this file.
4
+
5
+ Each edit entry replaces an inclusive anchor range:
6
+ ```json
7
+ { "range": [startAnchor, endAnchor], "lines": [...] }
8
+ ```
9
+ - `range` — `[start, end]` pair of LINE#HASH anchors from the most recent `read` or diff output.
10
+ Use the same anchor twice for single-line: `["42#A4", "42#A4"]`.
11
+ - `lines` — new content replacing the range (string array). Use `[]` to delete.
12
+ Must be literal file content, not LINE#HASH│-prefixed output. Match indentation exactly.
13
+
14
+ Example:
15
+ ```json
16
+ { "path": "src/main.ts", "edits": [
17
+ { "range": ["12#3F", "12#3F"], "lines": ["const x = 1;"] },
18
+ { "range": ["20#B2", "25#C7"], "lines": ["function foo() {", " return 42;", "}"] }
19
+ ] }
20
+
21
+ Rules:
22
+ - Do not guess, shift, or construct anchors. Copy them from the most recent `read` or diff output of this file.
23
+ - Do not emit overlapping or adjacent edits — merge them into one.
@@ -1,2 +1,2 @@
1
- - Use read before edit when you do not have current LINE#HASH anchors for the file.
1
+ - Use read before edit when you do not have current LINE#HASH anchors for the file.
2
2
  - If read is truncated, continue with the `offset` it suggests — do not guess unseen lines.
@@ -1,5 +1,5 @@
1
- Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASH│content` — copy those anchors verbatim into `edit`.
2
-
3
- Use `offset` and `limit` to page through. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}; when truncated, the tail of the output tells you the next `offset`.
4
-
5
- Set `raw: true` to skip LINE#HASH prefixing and return plain text. Don't use if you plan to edit this file — saves tokens on exploration, documentation, and reference reads.
1
+ Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASH│content` — copy those anchors verbatim into `edit`.
2
+
3
+ Use `offset` and `limit` to page through. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}; when truncated, the tail of the output tells you the next `offset`.
4
+
5
+ Set `raw: true` to skip LINE#HASH prefixing and return plain text. Don't use if you plan to edit this file — saves tokens on exploration, documentation, and reference reads.
@@ -0,0 +1,8 @@
1
+ Undo the most recent hashline edit. No parameters.
2
+
3
+ Use this when an `edit` call corrupted a file and you want to revert it immediately without a full rewrite.
4
+
5
+ Limitations:
6
+ - Only the most recent edit can be undone. A second `undo` will fail.
7
+ - Undo is only available within 3 turns of the edit. After that, use `read` and `edit` to fix the file.
8
+ - Undo state does not survive session switches, reloads, or restarts.
File without changes
File without changes