pi-hashline-edit-pro 2.6.4 → 2.7.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/README.md +78 -28
- package/index.ts +10 -5
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +6 -0
- package/prompts/grep-snippet.md +1 -0
- package/prompts/grep.md +1 -0
- package/prompts/insert-guidelines.md +6 -0
- package/prompts/insert-snippet.md +1 -0
- package/prompts/insert.md +1 -0
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -5
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -0
- package/prompts/undo-last-change-snippet.md +1 -0
- package/prompts/undo-last-change.md +1 -0
- package/src/commit.ts +107 -0
- package/src/constants.ts +1 -2
- package/src/file-kind.ts +1 -1
- package/src/file-reader.ts +5 -1
- package/src/grep.ts +323 -0
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/hash.ts +1 -1
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +3 -3
- package/src/hashline/resolve.ts +55 -29
- package/src/insert.ts +222 -0
- package/src/read.ts +2 -2
- package/src/replace-diff.ts +47 -6
- package/src/replace-render.ts +117 -2
- package/src/replace-response.ts +12 -6
- package/src/replace-undo.ts +12 -12
- package/src/replace.ts +43 -245
- package/src/validation.ts +1 -1
- package/prompts/undo-last-replace-guidelines.md +0 -2
- package/prompts/undo-last-replace-snippet.md +0 -1
- package/prompts/undo-last-replace.md +0 -1
package/src/insert.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { constants } from "fs";
|
|
5
|
+
import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
|
|
6
|
+
import { commitEdit } from "./commit";
|
|
7
|
+
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
+
import { resolveTarget } from "./fs-write";
|
|
9
|
+
import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
|
|
10
|
+
import { stripAnchorRow } from "./hashline/resolve";
|
|
11
|
+
import { toCwd } from "./paths";
|
|
12
|
+
import { loadP, loadGuide } from "./prompts";
|
|
13
|
+
import { normReq } from "./replace-normalize";
|
|
14
|
+
import { genDiff } from "./replace-diff";
|
|
15
|
+
import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
|
|
16
|
+
import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
|
|
17
|
+
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
18
|
+
|
|
19
|
+
const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
|
|
20
|
+
|
|
21
|
+
export interface InsertReq {
|
|
22
|
+
path: string;
|
|
23
|
+
anchor: string;
|
|
24
|
+
direction: "before" | "after";
|
|
25
|
+
lines: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertInsertReq(request: unknown): asserts request is InsertReq {
|
|
29
|
+
if (!isRec(request)) {
|
|
30
|
+
throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
|
|
31
|
+
}
|
|
32
|
+
rejectUnknownFields(request, INSERT_KS, "Insert request");
|
|
33
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
34
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
|
|
35
|
+
}
|
|
36
|
+
if (typeof request.anchor !== "string" || request.anchor.length === 0) {
|
|
37
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char hash from read output).');
|
|
38
|
+
}
|
|
39
|
+
if (request.direction !== "before" && request.direction !== "after") {
|
|
40
|
+
throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
|
|
43
|
+
throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const insertToolSchema = Type.Object(
|
|
48
|
+
{
|
|
49
|
+
path: Type.String({
|
|
50
|
+
description:
|
|
51
|
+
"Path to the file to edit",
|
|
52
|
+
}),
|
|
53
|
+
anchor: Type.String({
|
|
54
|
+
description:
|
|
55
|
+
'Bare 3-char HASH only (e.g. "aB3"): copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or a `HASH│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
|
|
56
|
+
}),
|
|
57
|
+
direction: Type.Union(
|
|
58
|
+
[
|
|
59
|
+
Type.Literal("after", { description: "Insert the lines after the anchor line" }),
|
|
60
|
+
Type.Literal("before", { description: "Insert the lines before the anchor line" }),
|
|
61
|
+
],
|
|
62
|
+
{ description: '"after" or "before"' },
|
|
63
|
+
),
|
|
64
|
+
lines: Type.Array(
|
|
65
|
+
Type.String({
|
|
66
|
+
description:
|
|
67
|
+
"One line to insert. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
|
|
68
|
+
}),
|
|
69
|
+
{
|
|
70
|
+
description:
|
|
71
|
+
'Lines to insert as an array of strings, one element per line. Use [""] for a blank line. The anchor line is preserved; never include it in lines.',
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
{ additionalProperties: false },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
|
|
79
|
+
const trimmedAnchor = raw.trim();
|
|
80
|
+
const warnings: string[] = [];
|
|
81
|
+
const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
|
|
82
|
+
return { ref: parseHashRef(anchorText), warnings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildInsertEdit(
|
|
86
|
+
req: InsertReq,
|
|
87
|
+
preload: NormFile,
|
|
88
|
+
ref: Anchor,
|
|
89
|
+
): { editParams: ReqParams; anchorLine: string | undefined } {
|
|
90
|
+
const fileLines = splitLines(preload.normalized);
|
|
91
|
+
const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, req.path);
|
|
92
|
+
const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
|
|
93
|
+
const editParams: ReqParams = {
|
|
94
|
+
path: req.path,
|
|
95
|
+
remove_from: ref.hash,
|
|
96
|
+
remove_to: ref.hash,
|
|
97
|
+
replacement_lines:
|
|
98
|
+
anchorLine === undefined
|
|
99
|
+
? [...req.lines]
|
|
100
|
+
: req.direction === "after"
|
|
101
|
+
? [anchorLine, ...req.lines]
|
|
102
|
+
: [...req.lines, anchorLine],
|
|
103
|
+
};
|
|
104
|
+
return { editParams, anchorLine };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function insertPreview(request: unknown, cwd: string): Promise<RPreview> {
|
|
108
|
+
try {
|
|
109
|
+
const normalized = normReq(request);
|
|
110
|
+
assertInsertReq(normalized);
|
|
111
|
+
const { ref } = parseInsertAnchor(normalized.anchor);
|
|
112
|
+
const preload = await readNormFile(normalized.path, cwd, {
|
|
113
|
+
accessMode: constants.R_OK,
|
|
114
|
+
maxLines: MAX_HASH_LINES,
|
|
115
|
+
noPersist: true,
|
|
116
|
+
});
|
|
117
|
+
const { editParams } = buildInsertEdit(normalized, preload, ref);
|
|
118
|
+
const pipe = await execPipeline(editParams, cwd, {
|
|
119
|
+
accessMode: constants.R_OK,
|
|
120
|
+
noPersist: true,
|
|
121
|
+
preloadedNorm: preload,
|
|
122
|
+
skipBoundaryDedup: true,
|
|
123
|
+
});
|
|
124
|
+
if (pipe.originalNormalized === pipe.result) {
|
|
125
|
+
return { error: `No changes made to ${normalized.path}. The edit produced identical content.` };
|
|
126
|
+
}
|
|
127
|
+
return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string[] } | null {
|
|
134
|
+
let normalized: unknown;
|
|
135
|
+
try {
|
|
136
|
+
normalized = normReq(args);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!isRec(normalized) || typeof normalized.path !== "string") return null;
|
|
141
|
+
if (
|
|
142
|
+
typeof normalized.anchor !== "string" ||
|
|
143
|
+
(normalized.direction !== "before" && normalized.direction !== "after") ||
|
|
144
|
+
!Array.isArray(normalized.lines) ||
|
|
145
|
+
normalized.lines.some((line) => typeof line !== "string")
|
|
146
|
+
) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
path: normalized.path,
|
|
151
|
+
anchor: normalized.anchor,
|
|
152
|
+
direction: normalized.direction,
|
|
153
|
+
lines: normalized.lines,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type InsertToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
|
|
158
|
+
|
|
159
|
+
export function buildInsertToolDef(): InsertToolDef {
|
|
160
|
+
return {
|
|
161
|
+
name: "insert",
|
|
162
|
+
label: "Insert",
|
|
163
|
+
description: loadP("../prompts/insert.md"),
|
|
164
|
+
promptSnippet: loadP("../prompts/insert-snippet.md"),
|
|
165
|
+
promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
|
|
166
|
+
prepareArguments: makePrepareArguments(),
|
|
167
|
+
parameters: insertToolSchema,
|
|
168
|
+
renderShell: "default",
|
|
169
|
+
renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
|
|
170
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
171
|
+
return renderEditResult(
|
|
172
|
+
result as {
|
|
173
|
+
content?: Array<{ type: string; text?: string }>;
|
|
174
|
+
details?: ReplaceDetails;
|
|
175
|
+
},
|
|
176
|
+
isPartial,
|
|
177
|
+
theme,
|
|
178
|
+
context,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
183
|
+
const canonical = normReq(params);
|
|
184
|
+
assertInsertReq(canonical);
|
|
185
|
+
const req = canonical;
|
|
186
|
+
const path = req.path;
|
|
187
|
+
const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
|
|
188
|
+
const absolutePath = toCwd(path, ctx.cwd);
|
|
189
|
+
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
190
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
191
|
+
abortIf(signal);
|
|
192
|
+
const preload = await readNormFile(path, ctx.cwd, {
|
|
193
|
+
signal,
|
|
194
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
195
|
+
maxLines: MAX_HASH_LINES,
|
|
196
|
+
});
|
|
197
|
+
const { editParams, anchorLine } = buildInsertEdit(req, preload, ref);
|
|
198
|
+
const pipe = await execPipeline(editParams, ctx.cwd, {
|
|
199
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
200
|
+
signal,
|
|
201
|
+
preloadedNorm: preload,
|
|
202
|
+
skipBoundaryDedup: true,
|
|
203
|
+
});
|
|
204
|
+
return commitEdit(pipe, {
|
|
205
|
+
path,
|
|
206
|
+
absolutePath,
|
|
207
|
+
mutationTargetPath,
|
|
208
|
+
signal,
|
|
209
|
+
verb: "inserted",
|
|
210
|
+
noopNoun: "Insertion",
|
|
211
|
+
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
212
|
+
prefixWarnings: anchorWarnings,
|
|
213
|
+
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function regInsert(pi: ExtensionAPI): void {
|
|
221
|
+
pi.registerTool(buildInsertToolDef());
|
|
222
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -72,7 +72,7 @@ export async function fmtReadPreview(
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
return {
|
|
75
|
-
text: `Offset ${startLine} is beyond end of file (0 lines
|
|
75
|
+
text: `Offset ${startLine} is beyond end of file (0 lines). Use replace to insert content.`,
|
|
76
76
|
servedHashes: [],
|
|
77
77
|
};
|
|
78
78
|
}
|
|
@@ -114,7 +114,7 @@ export async function fmtReadPreview(
|
|
|
114
114
|
const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
|
|
115
115
|
const verb = oversized.length === 1 ? "exceeds" : "exceed";
|
|
116
116
|
const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
|
|
117
|
-
const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown
|
|
117
|
+
const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
|
|
118
118
|
let preview = skippedTruncation.content;
|
|
119
119
|
let nextOffset: number | undefined;
|
|
120
120
|
if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
|
package/src/replace-diff.ts
CHANGED
|
@@ -47,6 +47,10 @@ function fmtDiffLine(
|
|
|
47
47
|
return `${prefix}${hash}${HASH_SEP}${line}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function isBlankLine(line: string): boolean {
|
|
51
|
+
return line.trim().length === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
|
|
51
55
|
const isEllipsisMarker = (line: string | symbol): line is symbol =>
|
|
52
56
|
line === ELLIPSIS_MARKER;
|
|
@@ -99,15 +103,52 @@ export function genDiff(
|
|
|
99
103
|
let skipTail = 0;
|
|
100
104
|
|
|
101
105
|
if (!lastWasChange) {
|
|
102
|
-
|
|
106
|
+
let count = contextLines;
|
|
107
|
+
if (
|
|
108
|
+
contextLines > 0 &&
|
|
109
|
+
displayLines.length > count &&
|
|
110
|
+
isBlankLine(displayLines[displayLines.length - 1]!)
|
|
111
|
+
) {
|
|
112
|
+
count += 1;
|
|
113
|
+
}
|
|
114
|
+
count = Math.min(count, displayLines.length);
|
|
115
|
+
skipStart = displayLines.length - count;
|
|
103
116
|
linesToShow = displayLines.slice(skipStart);
|
|
104
117
|
} else if (nextPartIsChange && displayLines.length > contextLines * 2) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
let headCount = contextLines;
|
|
119
|
+
let tailCount = contextLines;
|
|
120
|
+
if (
|
|
121
|
+
contextLines > 0 &&
|
|
122
|
+
displayLines.length - headCount > tailCount &&
|
|
123
|
+
isBlankLine(displayLines[headCount - 1]!)
|
|
124
|
+
) {
|
|
125
|
+
headCount += 1;
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
contextLines > 0 &&
|
|
129
|
+
displayLines.length - tailCount > headCount &&
|
|
130
|
+
isBlankLine(displayLines[displayLines.length - tailCount]!)
|
|
131
|
+
) {
|
|
132
|
+
tailCount += 1;
|
|
133
|
+
}
|
|
134
|
+
const middleLen = displayLines.length - headCount - tailCount;
|
|
135
|
+
if (middleLen > 0) {
|
|
136
|
+
linesToShow = [
|
|
137
|
+
...displayLines.slice(0, headCount),
|
|
138
|
+
ELLIPSIS_MARKER,
|
|
139
|
+
...displayLines.slice(displayLines.length - tailCount),
|
|
140
|
+
];
|
|
141
|
+
skipMiddle = middleLen;
|
|
142
|
+
} else {
|
|
143
|
+
linesToShow = displayLines;
|
|
144
|
+
}
|
|
108
145
|
} else if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
let count = contextLines;
|
|
147
|
+
const firstLine = linesToShow[0];
|
|
148
|
+
if (contextLines > 0 && typeof firstLine === "string" && isBlankLine(firstLine)) count += 1;
|
|
149
|
+
count = Math.min(count, linesToShow.length);
|
|
150
|
+
linesToShow = linesToShow.slice(0, count);
|
|
151
|
+
skipTail = displayLines.length - count;
|
|
111
152
|
}
|
|
112
153
|
|
|
113
154
|
if (skipStart > 0) {
|
package/src/replace-render.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
1
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { normReq } from "./replace-normalize";
|
|
3
4
|
import type { ReqParams, ReplaceDetails } from "./replace";
|
|
@@ -89,17 +90,18 @@ export function fmtResult(diff: string, theme: FgT): string {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export function fmtCall(
|
|
92
|
-
args:
|
|
93
|
+
args: { path?: string } | undefined,
|
|
93
94
|
state: RRState,
|
|
94
95
|
expanded: boolean,
|
|
95
96
|
theme: CallT,
|
|
97
|
+
toolName = "replace",
|
|
96
98
|
): string {
|
|
97
99
|
const path = args?.path;
|
|
98
100
|
const pathDisplay =
|
|
99
101
|
typeof path === "string" && path.length > 0
|
|
100
102
|
? theme.fg("accent", path)
|
|
101
103
|
: theme.fg("toolOutput", "...");
|
|
102
|
-
let text = `${theme.fg("toolTitle", theme.bold(
|
|
104
|
+
let text = `${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`;
|
|
103
105
|
|
|
104
106
|
if (!state.preview) {
|
|
105
107
|
return text;
|
|
@@ -209,3 +211,116 @@ export function mkMdTheme(theme: MdTheme) {
|
|
|
209
211
|
}),
|
|
210
212
|
};
|
|
211
213
|
}
|
|
214
|
+
|
|
215
|
+
export const PREVIEW_DEBOUNCE_MS = 150;
|
|
216
|
+
|
|
217
|
+
export function reuseText(context: any, content: string): Text {
|
|
218
|
+
const t = context.lastComponent instanceof Text
|
|
219
|
+
? context.lastComponent
|
|
220
|
+
: new Text("", 0, 0);
|
|
221
|
+
t.setText(content);
|
|
222
|
+
return t;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
|
|
226
|
+
const m = context.lastComponent instanceof Markdown
|
|
227
|
+
? context.lastComponent
|
|
228
|
+
: new Markdown("", 0, 0, mkMdTheme(theme));
|
|
229
|
+
m.setText(content);
|
|
230
|
+
return m;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function makeRenderCall(
|
|
234
|
+
preview: (args: unknown, cwd: string) => Promise<RPreview>,
|
|
235
|
+
options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
|
|
236
|
+
) {
|
|
237
|
+
const getInput = options.getInput ?? getPreviewInput;
|
|
238
|
+
const toolName = options.toolName ?? "replace";
|
|
239
|
+
return (args: any, theme: CallT, context: any): Text => {
|
|
240
|
+
const previewInput = getInput(args);
|
|
241
|
+
const cancelPendingPreview = () => {
|
|
242
|
+
if (context.state.previewTimer) {
|
|
243
|
+
clearTimeout(context.state.previewTimer);
|
|
244
|
+
context.state.previewTimer = undefined;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
if (context.executionStarted) {
|
|
248
|
+
cancelPendingPreview();
|
|
249
|
+
context.state.argsKey = undefined;
|
|
250
|
+
context.state.preview = undefined;
|
|
251
|
+
context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
252
|
+
} else if (!context.argsComplete || !previewInput) {
|
|
253
|
+
cancelPendingPreview();
|
|
254
|
+
context.state.argsKey = undefined;
|
|
255
|
+
context.state.preview = undefined;
|
|
256
|
+
context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
257
|
+
} else {
|
|
258
|
+
const argsKey = JSON.stringify(previewInput);
|
|
259
|
+
if (context.state.argsKey !== argsKey) {
|
|
260
|
+
cancelPendingPreview();
|
|
261
|
+
context.state.argsKey = argsKey;
|
|
262
|
+
context.state.preview = undefined;
|
|
263
|
+
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
264
|
+
context.state.previewGeneration = previewGeneration;
|
|
265
|
+
context.state.previewTimer = setTimeout(() => {
|
|
266
|
+
context.state.previewTimer = undefined;
|
|
267
|
+
preview(args, context.cwd)
|
|
268
|
+
.then((result) => {
|
|
269
|
+
if (
|
|
270
|
+
context.state.argsKey === argsKey &&
|
|
271
|
+
context.state.previewGeneration === previewGeneration
|
|
272
|
+
) {
|
|
273
|
+
context.state.preview = result;
|
|
274
|
+
context.invalidate();
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
.catch((err: unknown) => {
|
|
278
|
+
if (
|
|
279
|
+
context.state.argsKey === argsKey &&
|
|
280
|
+
context.state.previewGeneration === previewGeneration
|
|
281
|
+
) {
|
|
282
|
+
context.state.preview = {
|
|
283
|
+
error: err instanceof Error ? err.message : String(err),
|
|
284
|
+
};
|
|
285
|
+
context.invalidate();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}, PREVIEW_DEBOUNCE_MS);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
292
|
+
text.setText(fmtCall(getInput(args) ?? undefined, context.state as RRState, context.expanded, theme, toolName));
|
|
293
|
+
return text;
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function renderEditResult(
|
|
298
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
299
|
+
isPartial: boolean,
|
|
300
|
+
theme: FgT,
|
|
301
|
+
context: any,
|
|
302
|
+
): Text | Markdown {
|
|
303
|
+
if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
|
|
304
|
+
const renderedText = getResultText(result);
|
|
305
|
+
const renderState = context.state as RRState | undefined;
|
|
306
|
+
if (renderState) {
|
|
307
|
+
if (renderState.previewTimer) {
|
|
308
|
+
clearTimeout(renderState.previewTimer);
|
|
309
|
+
renderState.previewTimer = undefined;
|
|
310
|
+
}
|
|
311
|
+
renderState.preview = undefined;
|
|
312
|
+
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
313
|
+
}
|
|
314
|
+
if (context.isError) {
|
|
315
|
+
return renderedText
|
|
316
|
+
? reuseText(context, `\n${theme.fg("error", renderedText)}`)
|
|
317
|
+
: new Text("", 0, 0);
|
|
318
|
+
}
|
|
319
|
+
if (isApplied(result.details)) {
|
|
320
|
+
const appliedText = buildAppliedText(renderedText, result.details, theme);
|
|
321
|
+
return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
|
|
322
|
+
}
|
|
323
|
+
if (!renderedText) return new Text("", 0, 0);
|
|
324
|
+
return reuseMarkdown(context, fmtResultMd(renderedText), theme);
|
|
325
|
+
}
|
|
326
|
+
|
package/src/replace-response.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ReplaceDetails } from "./replace";
|
|
|
2
2
|
import { genDiff, genPatch } from "./replace-diff";
|
|
3
3
|
import { visLines, clipLine } from "./utils";
|
|
4
4
|
|
|
5
|
-
type TResult = {
|
|
5
|
+
export type TResult = {
|
|
6
6
|
content: Array<{ type: "text"; text: string }>;
|
|
7
7
|
isError?: boolean;
|
|
8
8
|
details: ReplaceDetails;
|
|
@@ -38,6 +38,7 @@ export interface NoopInput {
|
|
|
38
38
|
snapshotId?: string;
|
|
39
39
|
editMeta: RMeta;
|
|
40
40
|
warnings: string[] | undefined;
|
|
41
|
+
boundaryRemovedLines?: number;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export interface SuccessInput {
|
|
@@ -88,20 +89,25 @@ function warnBlock(warnings: string[] | undefined): string {
|
|
|
88
89
|
return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
export function buildNoop(input: NoopInput): TResult {
|
|
92
|
+
export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
92
93
|
const {
|
|
93
94
|
path,
|
|
94
95
|
noopEdit,
|
|
95
96
|
snapshotId,
|
|
96
97
|
editMeta,
|
|
97
98
|
warnings,
|
|
99
|
+
boundaryRemovedLines,
|
|
98
100
|
} = input;
|
|
99
101
|
|
|
100
102
|
const noopDetailsText = noopEdit
|
|
101
|
-
?
|
|
103
|
+
? `${noopNoun} for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
|
|
102
104
|
: "The edit produced identical content.";
|
|
105
|
+
const dedupNote =
|
|
106
|
+
boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
|
|
107
|
+
? `\nBoundary dedup removed ${boundaryRemovedLines} line(s) from the replacement. Send the same edit again to apply it literally.`
|
|
108
|
+
: "";
|
|
103
109
|
|
|
104
|
-
const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(warnings)}`;
|
|
110
|
+
const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${dedupNote}${warnBlock(warnings)}`;
|
|
105
111
|
|
|
106
112
|
const metrics = buildMetrics({
|
|
107
113
|
classification: "noop",
|
|
@@ -123,14 +129,14 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
export function buildChanged(input: SuccessInput): TResult {
|
|
132
|
+
export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
|
|
127
133
|
const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
|
|
128
134
|
const resultLines = visLines(result);
|
|
129
135
|
const diffResult = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
|
|
130
136
|
const addedLines = editMeta.addedLines;
|
|
131
137
|
const removedLines = editMeta.removedLines;
|
|
132
138
|
const warningsBlock = warnBlock(warnings);
|
|
133
|
-
const successPrefix = `Successfully
|
|
139
|
+
const successPrefix = `Successfully ${verb} in ${path}.`;
|
|
134
140
|
const lineSummary = addedLines > 0 || removedLines > 0
|
|
135
141
|
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
136
142
|
: "";
|
package/src/replace-undo.ts
CHANGED
|
@@ -85,13 +85,13 @@ export async function clearUndo(path: string): Promise<void> {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
export function
|
|
88
|
+
export function regUndo(pi: ExtensionAPI): void {
|
|
89
89
|
pi.registerTool({
|
|
90
|
-
name: "
|
|
91
|
-
label: "Undo Last
|
|
92
|
-
description: loadP("../prompts/undo-last-
|
|
93
|
-
promptSnippet: loadP("../prompts/undo-last-
|
|
94
|
-
promptGuidelines: loadGuide("../prompts/undo-last-
|
|
90
|
+
name: "undo_last_change",
|
|
91
|
+
label: "Undo Last Change",
|
|
92
|
+
description: loadP("../prompts/undo-last-change.md"),
|
|
93
|
+
promptSnippet: loadP("../prompts/undo-last-change-snippet.md"),
|
|
94
|
+
promptGuidelines: loadGuide("../prompts/undo-last-change-guidelines.md"),
|
|
95
95
|
prepareArguments: makePrepareArguments(),
|
|
96
96
|
parameters: Type.Object({
|
|
97
97
|
path: Type.String({
|
|
@@ -110,7 +110,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
110
110
|
content: [
|
|
111
111
|
{
|
|
112
112
|
type: "text",
|
|
113
|
-
text: `No undo history for ${path}
|
|
113
|
+
text: `No undo history for ${path}.`,
|
|
114
114
|
},
|
|
115
115
|
],
|
|
116
116
|
isError: true,
|
|
@@ -132,7 +132,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
132
132
|
content: [
|
|
133
133
|
{
|
|
134
134
|
type: "text",
|
|
135
|
-
text: `[E_UNDO_STALE] Cannot undo last
|
|
135
|
+
text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file no longer exists.`
|
|
136
136
|
},
|
|
137
137
|
],
|
|
138
138
|
isError: true,
|
|
@@ -145,7 +145,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
145
145
|
content: [
|
|
146
146
|
{
|
|
147
147
|
type: "text",
|
|
148
|
-
text: `[E_UNDO_STALE] Cannot undo last
|
|
148
|
+
text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file changed after the edit. Call read() to inspect the current state.`
|
|
149
149
|
},
|
|
150
150
|
],
|
|
151
151
|
isError: true,
|
|
@@ -178,15 +178,15 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
178
178
|
await clearUndo(mutationTargetPath);
|
|
179
179
|
|
|
180
180
|
const parts: string[] = [
|
|
181
|
-
`Undone last
|
|
181
|
+
`Undone last change on ${path}.`,
|
|
182
182
|
];
|
|
183
183
|
if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
|
|
184
184
|
parts.push(
|
|
185
|
-
`Removed ${linesAddedByReplace} line(s)
|
|
185
|
+
`Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
|
|
186
186
|
);
|
|
187
187
|
}
|
|
188
188
|
parts.push(
|
|
189
|
-
"
|
|
189
|
+
"Call read for fresh anchors.",
|
|
190
190
|
);
|
|
191
191
|
|
|
192
192
|
return {
|