@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.
Files changed (84) hide show
  1. package/README.md +94 -66
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/apply-patch/coordinator.ts +49 -0
  6. package/src/extensions/apply-patch/executor.ts +566 -0
  7. package/src/extensions/apply-patch/index.ts +74 -0
  8. package/src/extensions/apply-patch/matcher.ts +66 -0
  9. package/src/extensions/apply-patch/model.ts +34 -0
  10. package/src/extensions/apply-patch/parser.ts +381 -0
  11. package/src/extensions/apply-patch/render.ts +261 -0
  12. package/src/extensions/apply-patch/schema.ts +43 -0
  13. package/src/extensions/apply-patch/types.ts +30 -0
  14. package/src/extensions/bash/index.ts +3 -3
  15. package/src/extensions/edit/index.ts +2 -1
  16. package/src/extensions/glob/index.ts +3 -1
  17. package/src/extensions/glob/schema.ts +2 -1
  18. package/src/extensions/grep/index.ts +3 -1
  19. package/src/extensions/grep/render.ts +18 -4
  20. package/src/extensions/grep/schema.ts +1 -1
  21. package/src/extensions/read/index.ts +36 -9
  22. package/src/extensions/read/render.ts +31 -3
  23. package/src/extensions/subagent/index.ts +4 -1
  24. package/src/extensions/todo/index.ts +4 -3
  25. package/src/extensions/web-search/index.ts +2 -1
  26. package/src/extensions/write/index.ts +2 -1
  27. package/src/shared/PatchSummary.ts +82 -0
  28. package/src/telegram/Renderer.ts +190 -4
  29. package/src/extensions/bash/capture.test.ts +0 -126
  30. package/src/extensions/bash/format.test.ts +0 -240
  31. package/src/extensions/bash/run.test.ts +0 -262
  32. package/src/extensions/command-picker/ranker.test.ts +0 -46
  33. package/src/extensions/edit/edit.test.ts +0 -285
  34. package/src/extensions/file-picker/catalog.test.ts +0 -263
  35. package/src/extensions/file-picker/index.test.ts +0 -168
  36. package/src/extensions/file-picker/ranker.test.ts +0 -94
  37. package/src/extensions/footer/git.test.ts +0 -76
  38. package/src/extensions/footer/index.test.ts +0 -161
  39. package/src/extensions/footer/segments.test.ts +0 -164
  40. package/src/extensions/glob/glob.test.ts +0 -171
  41. package/src/extensions/glob/index.test.ts +0 -68
  42. package/src/extensions/glob/render.test.ts +0 -126
  43. package/src/extensions/grep/grep.test.ts +0 -387
  44. package/src/extensions/grep/index.test.ts +0 -68
  45. package/src/extensions/grep/render.test.ts +0 -269
  46. package/src/extensions/read/read.test.ts +0 -177
  47. package/src/extensions/read/render.test.ts +0 -61
  48. package/src/extensions/subagent/index.test.ts +0 -44
  49. package/src/extensions/subagent/render.test.ts +0 -292
  50. package/src/extensions/subagent/subagent.test.ts +0 -315
  51. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  52. package/src/extensions/todo/index.test.ts +0 -244
  53. package/src/extensions/todo/render.test.ts +0 -180
  54. package/src/extensions/todo/todo.test.ts +0 -222
  55. package/src/extensions/tps/index.test.ts +0 -254
  56. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  57. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  58. package/src/extensions/web-fetch/render.test.ts +0 -56
  59. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  60. package/src/extensions/web-search/render.test.ts +0 -21
  61. package/src/extensions/web-search/search.test.ts +0 -53
  62. package/src/extensions/working-indicator/index.test.ts +0 -21
  63. package/src/extensions/write/render.test.ts +0 -64
  64. package/src/extensions/write/write.test.ts +0 -108
  65. package/src/shared/DiffLines.test.ts +0 -193
  66. package/src/shared/DiffRenderer.test.ts +0 -206
  67. package/src/shared/EditMatcher.test.ts +0 -123
  68. package/src/shared/FileScanner.test.ts +0 -158
  69. package/src/shared/FuzzyMatcher.test.ts +0 -114
  70. package/src/shared/GitignoreFilter.test.ts +0 -64
  71. package/src/shared/Lines.test.ts +0 -25
  72. package/src/shared/McpClient.test.ts +0 -235
  73. package/src/shared/OutputBudget.test.ts +0 -99
  74. package/src/shared/Paths.test.ts +0 -51
  75. package/src/shared/PimSettings.test.ts +0 -90
  76. package/src/shared/Renderer.test.ts +0 -190
  77. package/src/shared/SpillCache.test.ts +0 -94
  78. package/src/shared/Tools.test.ts +0 -392
  79. package/src/telegram/Config.test.ts +0 -275
  80. package/src/telegram/Markdown.test.ts +0 -143
  81. package/src/telegram/Renderer.test.ts +0 -216
  82. package/src/telegram/SessionRegistry.test.ts +0 -89
  83. package/src/telegram/TaskScheduler.test.ts +0 -278
  84. 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
+ }