@aaroncql/pim-agent 0.0.1 → 0.2.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 +94 -66
- package/bin/pim.ts +55 -3
- package/package.json +20 -5
- package/src/extensions/_init/index.ts +3 -2
- package/src/extensions/apply-patch/coordinator.ts +49 -0
- package/src/extensions/apply-patch/executor.ts +566 -0
- package/src/extensions/apply-patch/index.ts +74 -0
- package/src/extensions/apply-patch/matcher.ts +66 -0
- package/src/extensions/apply-patch/model.ts +34 -0
- package/src/extensions/apply-patch/parser.ts +381 -0
- package/src/extensions/apply-patch/render.ts +261 -0
- package/src/extensions/apply-patch/schema.ts +43 -0
- package/src/extensions/apply-patch/types.ts +30 -0
- package/src/extensions/bash/index.ts +3 -3
- package/src/extensions/edit/index.ts +2 -1
- package/src/extensions/glob/index.ts +3 -1
- package/src/extensions/glob/schema.ts +2 -1
- package/src/extensions/grep/index.ts +3 -1
- package/src/extensions/grep/render.ts +18 -4
- package/src/extensions/grep/schema.ts +1 -1
- package/src/extensions/read/index.ts +36 -9
- package/src/extensions/read/render.ts +31 -3
- package/src/extensions/subagent/index.ts +4 -1
- package/src/extensions/todo/index.ts +4 -3
- package/src/extensions/web-search/index.ts +2 -1
- package/src/extensions/write/index.ts +2 -1
- package/src/shared/PatchSummary.ts +82 -0
- package/src/telegram/Renderer.ts +190 -4
- package/src/extensions/bash/capture.test.ts +0 -126
- package/src/extensions/bash/format.test.ts +0 -240
- package/src/extensions/bash/run.test.ts +0 -262
- package/src/extensions/command-picker/ranker.test.ts +0 -46
- package/src/extensions/edit/edit.test.ts +0 -285
- package/src/extensions/file-picker/catalog.test.ts +0 -263
- package/src/extensions/file-picker/index.test.ts +0 -168
- package/src/extensions/file-picker/ranker.test.ts +0 -94
- package/src/extensions/footer/git.test.ts +0 -76
- package/src/extensions/footer/index.test.ts +0 -161
- package/src/extensions/footer/segments.test.ts +0 -164
- package/src/extensions/glob/glob.test.ts +0 -171
- package/src/extensions/glob/index.test.ts +0 -68
- package/src/extensions/glob/render.test.ts +0 -126
- package/src/extensions/grep/grep.test.ts +0 -387
- package/src/extensions/grep/index.test.ts +0 -68
- package/src/extensions/grep/render.test.ts +0 -269
- package/src/extensions/read/read.test.ts +0 -177
- package/src/extensions/read/render.test.ts +0 -61
- package/src/extensions/subagent/index.test.ts +0 -44
- package/src/extensions/subagent/render.test.ts +0 -292
- package/src/extensions/subagent/subagent.test.ts +0 -315
- package/src/extensions/system-prompt/prompt.test.ts +0 -64
- package/src/extensions/todo/index.test.ts +0 -244
- package/src/extensions/todo/render.test.ts +0 -180
- package/src/extensions/todo/todo.test.ts +0 -222
- package/src/extensions/tps/index.test.ts +0 -254
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
- package/src/extensions/web-fetch/fetch.test.ts +0 -244
- package/src/extensions/web-fetch/render.test.ts +0 -56
- package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
- package/src/extensions/web-search/render.test.ts +0 -21
- package/src/extensions/web-search/search.test.ts +0 -53
- package/src/extensions/working-indicator/index.test.ts +0 -21
- package/src/extensions/write/render.test.ts +0 -64
- package/src/extensions/write/write.test.ts +0 -108
- package/src/shared/DiffLines.test.ts +0 -193
- package/src/shared/DiffRenderer.test.ts +0 -206
- package/src/shared/EditMatcher.test.ts +0 -123
- package/src/shared/FileScanner.test.ts +0 -158
- package/src/shared/FuzzyMatcher.test.ts +0 -114
- package/src/shared/GitignoreFilter.test.ts +0 -64
- package/src/shared/Lines.test.ts +0 -25
- package/src/shared/McpClient.test.ts +0 -235
- package/src/shared/OutputBudget.test.ts +0 -99
- package/src/shared/Paths.test.ts +0 -51
- package/src/shared/PimSettings.test.ts +0 -90
- package/src/shared/Renderer.test.ts +0 -190
- package/src/shared/SpillCache.test.ts +0 -94
- package/src/shared/Tools.test.ts +0 -392
- package/src/telegram/Config.test.ts +0 -275
- package/src/telegram/Markdown.test.ts +0 -143
- package/src/telegram/Renderer.test.ts +0 -216
- package/src/telegram/SessionRegistry.test.ts +0 -89
- package/src/telegram/TaskScheduler.test.ts +0 -278
- package/src/telegram/TaskTool.test.ts +0 -179
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
type ModelLike = {
|
|
2
|
+
readonly provider?: string;
|
|
3
|
+
readonly api?: string;
|
|
4
|
+
readonly id?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Conservative GPT/Codex-family detection. False positives demote a model to a
|
|
9
|
+
* tool it wasn't trained on, so misses beat false positives. Must NOT trigger
|
|
10
|
+
* for anthropic, nor for GPT-*named* non-OpenAI models served by aggregators
|
|
11
|
+
* (e.g. `eleutherai/gpt-neo`). It DOES trigger for a real OpenAI model routed
|
|
12
|
+
* through a gateway, identified by a vendor-namespaced id (`openai/gpt-4o`).
|
|
13
|
+
*/
|
|
14
|
+
export function isGptModel(model: ModelLike | undefined): boolean {
|
|
15
|
+
const provider = (model?.provider ?? "").toLowerCase();
|
|
16
|
+
const api = (model?.api ?? "").toLowerCase();
|
|
17
|
+
const id = (model?.id ?? "").toLowerCase();
|
|
18
|
+
|
|
19
|
+
// OpenAI is identified by the provider, or by a vendor-namespaced id used by
|
|
20
|
+
// aggregators (openrouter / vercel gateway serve "openai/gpt-4o"). Requiring
|
|
21
|
+
// the explicit "openai/" prefix still excludes GPT-named non-OpenAI models
|
|
22
|
+
// routed through the same aggregators.
|
|
23
|
+
const isOpenAi = provider.includes("openai") || id.startsWith("openai/");
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
provider.includes("codex") ||
|
|
27
|
+
api.includes("codex") ||
|
|
28
|
+
id.includes("codex") ||
|
|
29
|
+
(isOpenAi && id.includes("gpt")) ||
|
|
30
|
+
(isOpenAi && /(^|\/)o\d/.test(id)) ||
|
|
31
|
+
((provider.includes("copilot") || api.includes("copilot")) &&
|
|
32
|
+
id.includes("gpt"))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import type { Hunk, Patch, UpdateChunk } from "./types";
|
|
2
|
+
|
|
3
|
+
const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
4
|
+
const END_PATCH_MARKER = "*** End Patch";
|
|
5
|
+
export const ADD_FILE_MARKER = "*** Add File: ";
|
|
6
|
+
export const DELETE_FILE_MARKER = "*** Delete File: ";
|
|
7
|
+
export const UPDATE_FILE_MARKER = "*** Update File: ";
|
|
8
|
+
const MOVE_TO_PREFIX = "*** Move to:";
|
|
9
|
+
const EOF_MARKER = "*** End of File";
|
|
10
|
+
const CHANGE_CONTEXT_MARKER = "@@ ";
|
|
11
|
+
const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
|
|
12
|
+
|
|
13
|
+
type ParseErrorDetails =
|
|
14
|
+
| { readonly type: "patch"; readonly message: string }
|
|
15
|
+
| {
|
|
16
|
+
readonly type: "hunk";
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly lineNumber: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Match Codex's ParseError Display formats verbatim so GPT models see the exact
|
|
23
|
+
* error strings they were trained to recover from.
|
|
24
|
+
*/
|
|
25
|
+
function formatParseError(error: ParseErrorDetails): string {
|
|
26
|
+
switch (error.type) {
|
|
27
|
+
case "patch":
|
|
28
|
+
return `invalid patch: ${error.message}`;
|
|
29
|
+
case "hunk":
|
|
30
|
+
return `invalid hunk at line ${error.lineNumber}, ${error.message}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strict envelope + hunk parser, faithfully ported from Codex's
|
|
36
|
+
* `parse_patch_text` (strict mode). The envelope check trims the whole text,
|
|
37
|
+
* requires the first line to start with `*** Begin Patch` and the last line to
|
|
38
|
+
* trim to exactly `*** End Patch`. Paths have a leading `@` and surrounding
|
|
39
|
+
* quotes stripped.
|
|
40
|
+
*/
|
|
41
|
+
export function parsePatch(text: string): Patch {
|
|
42
|
+
const lines = text.trim().split("\n");
|
|
43
|
+
checkBoundaries(lines);
|
|
44
|
+
|
|
45
|
+
const hunkLines = lines.slice(1, lines.length - 1);
|
|
46
|
+
const hunks: Hunk[] = [];
|
|
47
|
+
let remaining = hunkLines;
|
|
48
|
+
let lineNumber = 2;
|
|
49
|
+
|
|
50
|
+
while (remaining.length > 0) {
|
|
51
|
+
const { hunk, consumed } = parseOneHunk(remaining, lineNumber);
|
|
52
|
+
hunks.push(hunk);
|
|
53
|
+
lineNumber += consumed;
|
|
54
|
+
remaining = remaining.slice(consumed);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { hunks };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkBoundaries(lines: readonly string[]): void {
|
|
61
|
+
const first = lines[0]?.trim();
|
|
62
|
+
const last = lines.at(-1)?.trim();
|
|
63
|
+
|
|
64
|
+
if (first === undefined || !lines[0]!.trim().startsWith(BEGIN_PATCH_MARKER)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
formatParseError({
|
|
67
|
+
type: "patch",
|
|
68
|
+
message:
|
|
69
|
+
"The first line of the patch must be '*** Begin Patch'. Do not include Markdown fences, prose, or shell heredoc text before it.",
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (last !== END_PATCH_MARKER) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
formatParseError({
|
|
77
|
+
type: "patch",
|
|
78
|
+
message:
|
|
79
|
+
"The last line of the patch must be '*** End Patch'. Do not include Markdown fences or trailing prose after it.",
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseOneHunk(
|
|
86
|
+
lines: readonly string[],
|
|
87
|
+
lineNumber: number
|
|
88
|
+
): { readonly hunk: Hunk; readonly consumed: number } {
|
|
89
|
+
const firstLine = lines[0]!.trim();
|
|
90
|
+
|
|
91
|
+
const addPath = stripPrefix(firstLine, ADD_FILE_MARKER);
|
|
92
|
+
if (addPath !== undefined) {
|
|
93
|
+
let contents = "";
|
|
94
|
+
let consumed = 1;
|
|
95
|
+
for (const line of lines.slice(1)) {
|
|
96
|
+
if (line.startsWith("+")) {
|
|
97
|
+
contents += `${line.slice(1)}\n`;
|
|
98
|
+
consumed += 1;
|
|
99
|
+
} else {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const nextLine = lines[consumed];
|
|
104
|
+
if (
|
|
105
|
+
nextLine !== undefined &&
|
|
106
|
+
!isHunkHeader(nextLine) &&
|
|
107
|
+
!nextLine.trim().startsWith("*")
|
|
108
|
+
) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
formatParseError({
|
|
111
|
+
type: "hunk",
|
|
112
|
+
lineNumber: lineNumber + consumed,
|
|
113
|
+
message: `Invalid Add File body: '${nextLine}' must start with '+'. Added file content lines must start with '+'.`,
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
hunk: { kind: "add", path: cleanPath(addPath), contents },
|
|
119
|
+
consumed,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const deletePath = stripPrefix(firstLine, DELETE_FILE_MARKER);
|
|
124
|
+
if (deletePath !== undefined) {
|
|
125
|
+
const nextLine = lines[1];
|
|
126
|
+
if (nextLine !== undefined && !isHunkHeader(nextLine)) {
|
|
127
|
+
if (nextLine.trim().startsWith("*")) {
|
|
128
|
+
return {
|
|
129
|
+
hunk: { kind: "delete", path: cleanPath(deletePath) },
|
|
130
|
+
consumed: 1,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
throw new Error(
|
|
134
|
+
formatParseError({
|
|
135
|
+
type: "hunk",
|
|
136
|
+
lineNumber: lineNumber + 1,
|
|
137
|
+
message: `Delete File hunks must not contain content lines, got: '${nextLine}'.`,
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
hunk: { kind: "delete", path: cleanPath(deletePath) },
|
|
143
|
+
consumed: 1,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const updatePath = stripPrefix(firstLine, UPDATE_FILE_MARKER);
|
|
148
|
+
if (updatePath !== undefined) {
|
|
149
|
+
let remaining = lines.slice(1);
|
|
150
|
+
let consumed = 1;
|
|
151
|
+
|
|
152
|
+
let movePath: string | undefined;
|
|
153
|
+
const moveLine = remaining[0]?.trim();
|
|
154
|
+
if (moveLine?.startsWith(MOVE_TO_PREFIX)) {
|
|
155
|
+
const rawMovePath = moveLine.slice(MOVE_TO_PREFIX.length);
|
|
156
|
+
if (rawMovePath.length === 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
formatParseError({
|
|
159
|
+
type: "hunk",
|
|
160
|
+
lineNumber: lineNumber + consumed,
|
|
161
|
+
message:
|
|
162
|
+
"Invalid *** Move to directive: destination path is required.",
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (!rawMovePath.startsWith(" ")) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
formatParseError({
|
|
169
|
+
type: "hunk",
|
|
170
|
+
lineNumber: lineNumber + consumed,
|
|
171
|
+
message: `Invalid *** Move to directive: use '*** Move to: {path}'.`,
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (cleanPath(rawMovePath).length === 0) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
formatParseError({
|
|
178
|
+
type: "hunk",
|
|
179
|
+
lineNumber: lineNumber + consumed,
|
|
180
|
+
message:
|
|
181
|
+
"Invalid *** Move to directive: destination path is required.",
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
movePath = rawMovePath;
|
|
186
|
+
} else if (moveLine?.startsWith("*** Move")) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
formatParseError({
|
|
189
|
+
type: "hunk",
|
|
190
|
+
lineNumber: lineNumber + consumed,
|
|
191
|
+
message: `Invalid move directive '${moveLine}'. Use '*** Move to: {path}'.`,
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (movePath !== undefined) {
|
|
196
|
+
remaining = remaining.slice(1);
|
|
197
|
+
consumed += 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const chunks: UpdateChunk[] = [];
|
|
201
|
+
while (remaining.length > 0) {
|
|
202
|
+
if (remaining[0]!.trim() === "") {
|
|
203
|
+
consumed += 1;
|
|
204
|
+
remaining = remaining.slice(1);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (remaining[0]!.startsWith("*")) {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { chunk, consumed: chunkLines } = parseUpdateChunk(
|
|
212
|
+
remaining,
|
|
213
|
+
lineNumber + consumed,
|
|
214
|
+
chunks.length === 0
|
|
215
|
+
);
|
|
216
|
+
chunks.push(chunk);
|
|
217
|
+
consumed += chunkLines;
|
|
218
|
+
remaining = remaining.slice(chunkLines);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// An Update with a Move to and no hunks is a valid pure rename; only an
|
|
222
|
+
// Update with neither a move nor any hunks is truly empty.
|
|
223
|
+
if (chunks.length === 0 && movePath === undefined) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
formatParseError({
|
|
226
|
+
type: "hunk",
|
|
227
|
+
lineNumber,
|
|
228
|
+
message: `Update file hunk for path '${cleanPath(updatePath)}' is empty. Include @@ plus at least one context, added, or removed line, or add *** Move to for a pure rename.`,
|
|
229
|
+
})
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
hunk: {
|
|
235
|
+
kind: "update",
|
|
236
|
+
path: cleanPath(updatePath),
|
|
237
|
+
movePath: movePath === undefined ? undefined : cleanPath(movePath),
|
|
238
|
+
chunks,
|
|
239
|
+
},
|
|
240
|
+
consumed,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error(
|
|
245
|
+
formatParseError({
|
|
246
|
+
type: "hunk",
|
|
247
|
+
lineNumber,
|
|
248
|
+
message:
|
|
249
|
+
`'${firstLine}' is not a valid hunk header. ` +
|
|
250
|
+
"Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'. " +
|
|
251
|
+
"Do not use unified-diff file headers like '---' or '+++' as hunk headers.",
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseUpdateChunk(
|
|
257
|
+
lines: readonly string[],
|
|
258
|
+
lineNumber: number,
|
|
259
|
+
allowMissingContext: boolean
|
|
260
|
+
): { readonly chunk: UpdateChunk; readonly consumed: number } {
|
|
261
|
+
let changeContext: string | undefined;
|
|
262
|
+
let startIndex: number;
|
|
263
|
+
|
|
264
|
+
if (lines[0] === EMPTY_CHANGE_CONTEXT_MARKER) {
|
|
265
|
+
changeContext = undefined;
|
|
266
|
+
startIndex = 1;
|
|
267
|
+
} else {
|
|
268
|
+
const ctx = stripPrefix(lines[0]!, CHANGE_CONTEXT_MARKER);
|
|
269
|
+
if (ctx !== undefined) {
|
|
270
|
+
changeContext = ctx;
|
|
271
|
+
startIndex = 1;
|
|
272
|
+
} else {
|
|
273
|
+
if (!allowMissingContext) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
formatParseError({
|
|
276
|
+
type: "hunk",
|
|
277
|
+
lineNumber,
|
|
278
|
+
message: `Expected update hunk to start with a @@ context marker, got: '${lines[0]}'. Start each additional edit chunk with @@ or @@ followed by nearby context.`,
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
changeContext = undefined;
|
|
283
|
+
startIndex = 0;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (startIndex >= lines.length) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
formatParseError({
|
|
290
|
+
type: "hunk",
|
|
291
|
+
lineNumber,
|
|
292
|
+
message:
|
|
293
|
+
"Update hunk does not contain any context, added, or removed lines. Include at least one line starting with ' ', '+', or '-'.",
|
|
294
|
+
})
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const oldLines: string[] = [];
|
|
299
|
+
const newLines: string[] = [];
|
|
300
|
+
let isEndOfFile = false;
|
|
301
|
+
let parsed = 0;
|
|
302
|
+
|
|
303
|
+
for (const line of lines.slice(startIndex)) {
|
|
304
|
+
if (line === EOF_MARKER) {
|
|
305
|
+
if (parsed === 0) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
formatParseError({
|
|
308
|
+
type: "hunk",
|
|
309
|
+
lineNumber,
|
|
310
|
+
message:
|
|
311
|
+
"Update hunk does not contain any context, added, or removed lines. Include at least one line starting with ' ', '+', or '-'.",
|
|
312
|
+
})
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
isEndOfFile = true;
|
|
316
|
+
parsed += 1;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const marker = line[0];
|
|
321
|
+
if (marker === undefined) {
|
|
322
|
+
oldLines.push("");
|
|
323
|
+
newLines.push("");
|
|
324
|
+
} else if (marker === " ") {
|
|
325
|
+
oldLines.push(line.slice(1));
|
|
326
|
+
newLines.push(line.slice(1));
|
|
327
|
+
} else if (marker === "+") {
|
|
328
|
+
newLines.push(line.slice(1));
|
|
329
|
+
} else if (marker === "-") {
|
|
330
|
+
oldLines.push(line.slice(1));
|
|
331
|
+
} else {
|
|
332
|
+
if (parsed === 0) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
formatParseError({
|
|
335
|
+
type: "hunk",
|
|
336
|
+
lineNumber,
|
|
337
|
+
message:
|
|
338
|
+
`Unexpected line found in update hunk: '${line}'. ` +
|
|
339
|
+
"Every line should start with ' ' (context line), '+' (added line), or '-' (removed line). " +
|
|
340
|
+
"Unchanged context lines must be prefixed with a single space.",
|
|
341
|
+
})
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
parsed += 1;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
chunk: { changeContext, oldLines, newLines, isEndOfFile },
|
|
351
|
+
consumed: parsed + startIndex,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function stripPrefix(value: string, prefix: string): string | undefined {
|
|
356
|
+
return value.startsWith(prefix) ? value.slice(prefix.length) : undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isHunkHeader(line: string): boolean {
|
|
360
|
+
const trimmed = line.trim();
|
|
361
|
+
return (
|
|
362
|
+
trimmed.startsWith(ADD_FILE_MARKER) ||
|
|
363
|
+
trimmed.startsWith(DELETE_FILE_MARKER) ||
|
|
364
|
+
trimmed.startsWith(UPDATE_FILE_MARKER)
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function cleanPath(raw: string): string {
|
|
369
|
+
let path = raw.trim();
|
|
370
|
+
if (path.startsWith("@")) {
|
|
371
|
+
path = path.slice(1).trim();
|
|
372
|
+
}
|
|
373
|
+
if (path.length >= 2) {
|
|
374
|
+
const first = path[0]!;
|
|
375
|
+
const last = path.at(-1)!;
|
|
376
|
+
if ((first === '"' || first === "'" || first === "`") && first === last) {
|
|
377
|
+
path = path.slice(1, -1);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return path;
|
|
381
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
Theme,
|
|
4
|
+
ToolRenderResultOptions,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { type Component, Container } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { ToolDiff } from "../../shared/DiffLines";
|
|
8
|
+
import {
|
|
9
|
+
type DiffRenderState,
|
|
10
|
+
type DiffStats,
|
|
11
|
+
DiffView,
|
|
12
|
+
} from "../../shared/DiffView";
|
|
13
|
+
import { Paths } from "../../shared/Paths";
|
|
14
|
+
import { PatchSummary } from "../../shared/PatchSummary";
|
|
15
|
+
import { type RenderContext, Renderer } from "../../shared/Renderer";
|
|
16
|
+
import type { ApplyEntry } from "./executor";
|
|
17
|
+
|
|
18
|
+
const ERROR_PREVIEW_LINES = 12;
|
|
19
|
+
// Rename separator. ➝ (U+279D) reads more vertically centered than → in most
|
|
20
|
+
// terminal fonts; swap here if a font renders it double-width.
|
|
21
|
+
const ARROW = "➝";
|
|
22
|
+
|
|
23
|
+
type ApplyPatchDetails = {
|
|
24
|
+
readonly entries?: readonly ApplyEntry[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ApplyPatchRenderContext = RenderContext & {
|
|
28
|
+
readonly cwd: string;
|
|
29
|
+
readonly state: DiffRenderState;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type EntryView = {
|
|
33
|
+
readonly label: string;
|
|
34
|
+
readonly title: string;
|
|
35
|
+
readonly stats: DiffStats;
|
|
36
|
+
// Body to render under the title; undefined => title only (delete, rename).
|
|
37
|
+
readonly body: ToolDiff | undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Draw an "Edit: <path>" title as soon as the call starts (mirroring the edit
|
|
41
|
+
// tool) so there's never a blank row and an error still gets a header. The
|
|
42
|
+
// title is for the first file and is updated in place on result.
|
|
43
|
+
export function renderApplyPatchCall(
|
|
44
|
+
args: Record<string, unknown> | undefined,
|
|
45
|
+
theme: Theme,
|
|
46
|
+
context: ApplyPatchRenderContext
|
|
47
|
+
): Component {
|
|
48
|
+
const input = typeof args?.input === "string" ? args.input : undefined;
|
|
49
|
+
const firstPath = input ? PatchSummary.firstPath(input) : undefined;
|
|
50
|
+
return DiffView.renderDiffCall({
|
|
51
|
+
label: "Edit",
|
|
52
|
+
rawPath: firstPath ? Paths.resolve(firstPath, context.cwd) : undefined,
|
|
53
|
+
theme,
|
|
54
|
+
context,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function blankLine(): Component {
|
|
59
|
+
return {
|
|
60
|
+
render: () => [""],
|
|
61
|
+
invalidate() {},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function describeEntry(
|
|
66
|
+
entry: ApplyEntry,
|
|
67
|
+
cwd: string,
|
|
68
|
+
theme: Theme
|
|
69
|
+
): EntryView {
|
|
70
|
+
const rel = (p: string): string =>
|
|
71
|
+
Paths.toForwardSlashes(Paths.displayRelative(Paths.resolve(p, cwd), cwd));
|
|
72
|
+
const stats = DiffView.countStats(entry.diff);
|
|
73
|
+
|
|
74
|
+
switch (entry.action.kind) {
|
|
75
|
+
case "add":
|
|
76
|
+
// A new file: reuse the write-tool look (green content body).
|
|
77
|
+
return {
|
|
78
|
+
label: "Write",
|
|
79
|
+
title: rel(entry.action.path),
|
|
80
|
+
stats,
|
|
81
|
+
body: entry.diff,
|
|
82
|
+
};
|
|
83
|
+
case "delete":
|
|
84
|
+
// Title only with a -N stat; don't dump the removed file as a red diff.
|
|
85
|
+
return {
|
|
86
|
+
label: "Delete",
|
|
87
|
+
title: rel(entry.action.path),
|
|
88
|
+
stats,
|
|
89
|
+
body: undefined,
|
|
90
|
+
};
|
|
91
|
+
case "move":
|
|
92
|
+
// A pure move has no body; a move with content changes still renders as an edit.
|
|
93
|
+
return {
|
|
94
|
+
label: entry.diff ? "Edit" : "Move",
|
|
95
|
+
title: formatMoveTitle(
|
|
96
|
+
rel(entry.action.path),
|
|
97
|
+
rel(entry.action.movePath ?? entry.action.path),
|
|
98
|
+
theme
|
|
99
|
+
),
|
|
100
|
+
stats,
|
|
101
|
+
body: entry.diff,
|
|
102
|
+
};
|
|
103
|
+
default:
|
|
104
|
+
return {
|
|
105
|
+
label: "Edit",
|
|
106
|
+
title: rel(entry.action.path),
|
|
107
|
+
stats,
|
|
108
|
+
body: entry.diff,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatMoveTitle(
|
|
114
|
+
oldPath: string,
|
|
115
|
+
newPath: string,
|
|
116
|
+
theme: Theme
|
|
117
|
+
): string {
|
|
118
|
+
const oldParts = oldPath.split("/");
|
|
119
|
+
const newParts = newPath.split("/");
|
|
120
|
+
let commonPrefix = 0;
|
|
121
|
+
|
|
122
|
+
while (
|
|
123
|
+
commonPrefix < oldParts.length &&
|
|
124
|
+
commonPrefix < newParts.length &&
|
|
125
|
+
oldParts[commonPrefix] === newParts[commonPrefix]
|
|
126
|
+
) {
|
|
127
|
+
commonPrefix += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let commonSuffix = 0;
|
|
131
|
+
while (
|
|
132
|
+
commonSuffix < oldParts.length - commonPrefix &&
|
|
133
|
+
commonSuffix < newParts.length - commonPrefix &&
|
|
134
|
+
oldParts[oldParts.length - commonSuffix - 1] ===
|
|
135
|
+
newParts[newParts.length - commonSuffix - 1]
|
|
136
|
+
) {
|
|
137
|
+
commonSuffix += 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const oldChanged = oldParts.slice(
|
|
141
|
+
commonPrefix,
|
|
142
|
+
oldParts.length - commonSuffix
|
|
143
|
+
);
|
|
144
|
+
const newChanged = newParts.slice(
|
|
145
|
+
commonPrefix,
|
|
146
|
+
newParts.length - commonSuffix
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
oldChanged.length > 0 &&
|
|
151
|
+
newChanged.length > 0 &&
|
|
152
|
+
(commonPrefix > 0 ||
|
|
153
|
+
commonSuffix > 0 ||
|
|
154
|
+
(oldParts.length === 1 && newParts.length === 1))
|
|
155
|
+
) {
|
|
156
|
+
const prefix =
|
|
157
|
+
commonPrefix > 0 ? `${oldParts.slice(0, commonPrefix).join("/")}/` : "";
|
|
158
|
+
const suffix =
|
|
159
|
+
commonSuffix > 0 ? `/${oldParts.slice(-commonSuffix).join("/")}` : "";
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
prefix +
|
|
163
|
+
dim(theme, "{") +
|
|
164
|
+
dim(theme, theme.strikethrough(oldChanged.join("/"))) +
|
|
165
|
+
dim(theme, ` ${ARROW} `) +
|
|
166
|
+
normalTitle(theme, newChanged.join("/")) +
|
|
167
|
+
dim(theme, "}") +
|
|
168
|
+
normalTitle(theme, suffix)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return `${dim(theme, theme.strikethrough(oldPath))} ${dim(
|
|
173
|
+
theme,
|
|
174
|
+
ARROW
|
|
175
|
+
)} ${normalTitle(theme, newPath)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function dim(theme: Theme, text: string): string {
|
|
179
|
+
return theme.fg("dim", text);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalTitle(theme: Theme, text: string): string {
|
|
183
|
+
return text === "" ? "" : theme.fg("toolTitle", text);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renderApplyPatchResult(
|
|
187
|
+
result: AgentToolResult<unknown>,
|
|
188
|
+
options: ToolRenderResultOptions,
|
|
189
|
+
theme: Theme,
|
|
190
|
+
context: ApplyPatchRenderContext
|
|
191
|
+
): Component {
|
|
192
|
+
const state = context.state;
|
|
193
|
+
const container =
|
|
194
|
+
(context.lastComponent as Container | undefined) ?? new Container();
|
|
195
|
+
container.clear();
|
|
196
|
+
|
|
197
|
+
if (options.isPartial) {
|
|
198
|
+
return container;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (context.isError) {
|
|
202
|
+
return Renderer.renderBorderedResult({
|
|
203
|
+
result,
|
|
204
|
+
options,
|
|
205
|
+
theme,
|
|
206
|
+
context: { ...context, isPartial: false },
|
|
207
|
+
previewLines: ERROR_PREVIEW_LINES,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const details = result.details as ApplyPatchDetails | undefined;
|
|
212
|
+
// A no-op update (rewrote identical content) has nothing to show; skip it.
|
|
213
|
+
const entries = (details?.entries ?? []).filter(
|
|
214
|
+
(entry) => !(entry.action.kind === "update" && entry.diff === undefined)
|
|
215
|
+
);
|
|
216
|
+
const markerColor = Renderer.markerColorFor(false, false);
|
|
217
|
+
|
|
218
|
+
entries.forEach((entry, index) => {
|
|
219
|
+
const view = describeEntry(entry, context.cwd, theme);
|
|
220
|
+
|
|
221
|
+
if (index === 0 && state?.titleComponent) {
|
|
222
|
+
// Reuse the call title for the first file, updating it in place.
|
|
223
|
+
DiffView.buildTitle({
|
|
224
|
+
label: view.label,
|
|
225
|
+
path: view.title,
|
|
226
|
+
stats: view.stats,
|
|
227
|
+
theme,
|
|
228
|
+
markerColor,
|
|
229
|
+
lastComponent: state.titleComponent,
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
// A blank padding row separates each file from the previous one.
|
|
233
|
+
if (index > 0) {
|
|
234
|
+
container.addChild(blankLine());
|
|
235
|
+
}
|
|
236
|
+
container.addChild(
|
|
237
|
+
DiffView.buildTitle({
|
|
238
|
+
label: view.label,
|
|
239
|
+
path: view.title,
|
|
240
|
+
stats: view.stats,
|
|
241
|
+
theme,
|
|
242
|
+
markerColor,
|
|
243
|
+
lastComponent: undefined,
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (view.body) {
|
|
249
|
+
container.addChild(
|
|
250
|
+
DiffView.buildBlock({
|
|
251
|
+
diff: view.body,
|
|
252
|
+
theme,
|
|
253
|
+
lastComponent: undefined,
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
container.invalidate();
|
|
260
|
+
return container;
|
|
261
|
+
}
|