@aaroncql/pim-agent 0.0.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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,307 @@
1
+ import * as Diff from "diff";
2
+
3
+ export type ToolDiffLineKind = "context" | "added" | "removed";
4
+
5
+ export type IntraLineRange = {
6
+ readonly start: number;
7
+ readonly end: number;
8
+ };
9
+
10
+ export type ToolDiffLine = {
11
+ readonly kind: ToolDiffLineKind;
12
+ readonly oldLine?: number;
13
+ readonly newLine?: number;
14
+ readonly text: string;
15
+ readonly emphasis?: readonly IntraLineRange[];
16
+ };
17
+
18
+ export type ToolDiffHunk = {
19
+ readonly oldStart: number;
20
+ readonly oldLines: number;
21
+ readonly newStart: number;
22
+ readonly newLines: number;
23
+ readonly lines: readonly ToolDiffLine[];
24
+ };
25
+
26
+ export type ToolDiff = {
27
+ readonly path: string;
28
+ readonly hunks: readonly ToolDiffHunk[];
29
+ };
30
+
31
+ // Logical lines (no trailing empty token) plus an explicit EOF-newline flag.
32
+ // Don't fold the flag back into the array: a raw `split("\n")` would make
33
+ // `joinComparable` emit a phantom blank line on every append.
34
+ export type ToolDiffSide = {
35
+ readonly lines: readonly string[];
36
+ readonly hasTrailingNewline: boolean;
37
+ };
38
+
39
+ export class DiffLines {
40
+ public static buildToolDiff(
41
+ path: string,
42
+ oldSide: ToolDiffSide,
43
+ newSide: ToolDiffSide,
44
+ contextSize: number
45
+ ): ToolDiff | undefined {
46
+ if (contextSize < 0 || !Number.isFinite(contextSize)) {
47
+ throw new Error(
48
+ `contextSize must be a non-negative finite number, got ${contextSize}.`
49
+ );
50
+ }
51
+
52
+ const lines = DiffLines.build(oldSide.lines, newSide.lines);
53
+
54
+ if (!lines.some((line) => line.kind !== "context")) {
55
+ return undefined;
56
+ }
57
+
58
+ const emphasized = DiffLines.attachEmphasis(lines);
59
+
60
+ return {
61
+ path,
62
+ hunks: DiffLines.buildHunks(emphasized, contextSize),
63
+ };
64
+ }
65
+
66
+ public static fromText(text: string): ToolDiffSide {
67
+ if (text.length === 0) {
68
+ return { lines: [], hasTrailingNewline: false };
69
+ }
70
+
71
+ const hasTrailingNewline = text.endsWith("\n");
72
+ const parts = text.split("\n");
73
+
74
+ if (hasTrailingNewline) {
75
+ parts.pop();
76
+ }
77
+
78
+ return { lines: parts, hasTrailingNewline };
79
+ }
80
+
81
+ private static attachEmphasis(
82
+ lines: readonly ToolDiffLine[]
83
+ ): readonly ToolDiffLine[] {
84
+ const result: ToolDiffLine[] = [...lines];
85
+ let i = 0;
86
+
87
+ while (i < result.length) {
88
+ if (result[i]?.kind !== "removed") {
89
+ i += 1;
90
+ continue;
91
+ }
92
+
93
+ let removedEnd = i;
94
+ while (
95
+ removedEnd < result.length &&
96
+ result[removedEnd]?.kind === "removed"
97
+ ) {
98
+ removedEnd += 1;
99
+ }
100
+
101
+ let addedEnd = removedEnd;
102
+ while (addedEnd < result.length && result[addedEnd]?.kind === "added") {
103
+ addedEnd += 1;
104
+ }
105
+
106
+ const removedCount = removedEnd - i;
107
+ const addedCount = addedEnd - removedEnd;
108
+
109
+ if (removedCount > 0 && removedCount === addedCount) {
110
+ for (let k = 0; k < removedCount; k += 1) {
111
+ const removed = result[i + k];
112
+ const added = result[removedEnd + k];
113
+
114
+ if (removed === undefined || added === undefined) {
115
+ continue;
116
+ }
117
+
118
+ const ranges = DiffLines.computeIntraLineRanges(
119
+ removed.text,
120
+ added.text
121
+ );
122
+
123
+ if (ranges === undefined) {
124
+ continue;
125
+ }
126
+
127
+ result[i + k] = { ...removed, emphasis: ranges.removed };
128
+ result[removedEnd + k] = { ...added, emphasis: ranges.added };
129
+ }
130
+ }
131
+
132
+ i = addedEnd > i ? addedEnd : i + 1;
133
+ }
134
+
135
+ return result;
136
+ }
137
+
138
+ private static computeIntraLineRanges(
139
+ oldText: string,
140
+ newText: string
141
+ ):
142
+ | {
143
+ readonly removed: readonly IntraLineRange[];
144
+ readonly added: readonly IntraLineRange[];
145
+ }
146
+ | undefined {
147
+ const parts = Diff.diffWords(oldText, newText);
148
+ const removedRanges: IntraLineRange[] = [];
149
+ const addedRanges: IntraLineRange[] = [];
150
+ let oldPos = 0;
151
+ let newPos = 0;
152
+ let sharedLen = 0;
153
+ let firstRemoved = true;
154
+ let firstAdded = true;
155
+
156
+ for (const part of parts) {
157
+ const len = part.value.length;
158
+
159
+ if (part.added === true) {
160
+ const leading = firstAdded ? DiffLines.leadingWsLen(part.value) : 0;
161
+ firstAdded = false;
162
+ if (len - leading > 0) {
163
+ addedRanges.push({ start: newPos + leading, end: newPos + len });
164
+ }
165
+ newPos += len;
166
+ continue;
167
+ }
168
+
169
+ if (part.removed === true) {
170
+ const leading = firstRemoved ? DiffLines.leadingWsLen(part.value) : 0;
171
+ firstRemoved = false;
172
+ if (len - leading > 0) {
173
+ removedRanges.push({ start: oldPos + leading, end: oldPos + len });
174
+ }
175
+ oldPos += len;
176
+ continue;
177
+ }
178
+
179
+ sharedLen += len;
180
+ oldPos += len;
181
+ newPos += len;
182
+ }
183
+
184
+ if (sharedLen === 0) {
185
+ return undefined;
186
+ }
187
+
188
+ return { removed: removedRanges, added: addedRanges };
189
+ }
190
+
191
+ private static build(
192
+ oldLines: readonly string[],
193
+ newLines: readonly string[]
194
+ ): readonly ToolDiffLine[] {
195
+ const parts = Diff.diffLines(
196
+ DiffLines.joinComparable(oldLines),
197
+ DiffLines.joinComparable(newLines)
198
+ );
199
+ const lines: ToolDiffLine[] = [];
200
+ let oldLine = 1;
201
+ let newLine = 1;
202
+
203
+ for (const part of parts) {
204
+ const values = DiffLines.partLines(part.value);
205
+
206
+ for (const text of values) {
207
+ if (part.added === true) {
208
+ lines.push({ kind: "added", newLine, text });
209
+ newLine += 1;
210
+ continue;
211
+ }
212
+
213
+ if (part.removed === true) {
214
+ lines.push({ kind: "removed", oldLine, text });
215
+ oldLine += 1;
216
+ continue;
217
+ }
218
+
219
+ lines.push({ kind: "context", oldLine, newLine, text });
220
+ oldLine += 1;
221
+ newLine += 1;
222
+ }
223
+ }
224
+
225
+ return lines;
226
+ }
227
+
228
+ private static buildHunks(
229
+ lines: readonly ToolDiffLine[],
230
+ contextSize: number
231
+ ): readonly ToolDiffHunk[] {
232
+ const changeIndexes = lines
233
+ .map((line, index) => (line.kind === "context" ? -1 : index))
234
+ .filter((index) => index >= 0);
235
+ const hunks: ToolDiffHunk[] = [];
236
+ let firstChange = changeIndexes[0];
237
+ let lastChange = changeIndexes[0];
238
+
239
+ if (firstChange === undefined || lastChange === undefined) {
240
+ return hunks;
241
+ }
242
+
243
+ const pushHunk = (startChange: number, endChange: number): void => {
244
+ const hunkLines = lines.slice(
245
+ Math.max(0, startChange - contextSize),
246
+ Math.min(lines.length, endChange + contextSize + 1)
247
+ );
248
+ const oldNumbers = hunkLines.flatMap((line) =>
249
+ line.oldLine === undefined ? [] : [line.oldLine]
250
+ );
251
+ const newNumbers = hunkLines.flatMap((line) =>
252
+ line.newLine === undefined ? [] : [line.newLine]
253
+ );
254
+ const oldStart =
255
+ oldNumbers.length === 0
256
+ ? Math.max(0, (newNumbers[0] ?? 1) - 1)
257
+ : Math.min(...oldNumbers);
258
+ const newStart =
259
+ newNumbers.length === 0
260
+ ? Math.max(0, (oldNumbers[0] ?? 1) - 1)
261
+ : Math.min(...newNumbers);
262
+
263
+ hunks.push({
264
+ oldStart,
265
+ oldLines:
266
+ oldNumbers.length === 0 ? 0 : Math.max(...oldNumbers) - oldStart + 1,
267
+ newStart,
268
+ newLines:
269
+ newNumbers.length === 0 ? 0 : Math.max(...newNumbers) - newStart + 1,
270
+ lines: hunkLines,
271
+ });
272
+ };
273
+
274
+ for (const changeIndex of changeIndexes.slice(1)) {
275
+ if (changeIndex - lastChange <= contextSize * 2 + 1) {
276
+ lastChange = changeIndex;
277
+ continue;
278
+ }
279
+
280
+ pushHunk(firstChange, lastChange);
281
+ firstChange = changeIndex;
282
+ lastChange = changeIndex;
283
+ }
284
+
285
+ pushHunk(firstChange, lastChange);
286
+
287
+ return hunks;
288
+ }
289
+
290
+ private static leadingWsLen(value: string): number {
291
+ return value.match(/^\s*/)?.[0].length ?? 0;
292
+ }
293
+
294
+ private static joinComparable(lines: readonly string[]): string {
295
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
296
+ }
297
+
298
+ private static partLines(value: string): readonly string[] {
299
+ const lines = value.split("\n");
300
+
301
+ if (lines.at(-1) === "") {
302
+ lines.pop();
303
+ }
304
+
305
+ return lines;
306
+ }
307
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DiffLines, type ToolDiffHunk } from "./DiffLines";
3
+ import { DiffRenderer, type DiffHighlighter } from "./DiffRenderer";
4
+
5
+ const tagHighlighter: DiffHighlighter = (block) =>
6
+ block.split("\n").map((line) => `<H>${line}</H>`);
7
+
8
+ const recordingHighlighter = (calls: string[][]): DiffHighlighter => {
9
+ return (block) => {
10
+ const lines = block.split("\n");
11
+ calls.push(lines);
12
+ return lines.map((line) => `<H>${line}</H>`);
13
+ };
14
+ };
15
+
16
+ const firstHunk = (
17
+ oldText: readonly string[],
18
+ newText: readonly string[]
19
+ ): ToolDiffHunk => {
20
+ const diff = DiffLines.buildToolDiff(
21
+ "foo.ts",
22
+ { lines: oldText, hasTrailingNewline: true },
23
+ { lines: newText, hasTrailingNewline: true },
24
+ 1
25
+ );
26
+
27
+ if (diff === undefined || diff.hunks[0] === undefined) {
28
+ throw new Error("expected at least one hunk");
29
+ }
30
+
31
+ return diff.hunks[0];
32
+ };
33
+
34
+ describe("DiffRenderer.highlightHunkLines", () => {
35
+ test("highlights added lines using the new-side block", () => {
36
+ const hunk = firstHunk(["a", "b", "c"], ["a", "b", "c", "d"]);
37
+ const result = DiffRenderer.highlightHunkLines(hunk, tagHighlighter);
38
+
39
+ expect(result).toEqual(hunk.lines.map((line) => `<H>${line.text}</H>`));
40
+ });
41
+
42
+ test("highlights removed lines using the old-side block", () => {
43
+ const hunk = firstHunk(["a", "b", "c"], ["a", "c"]);
44
+ const result = DiffRenderer.highlightHunkLines(hunk, tagHighlighter);
45
+
46
+ expect(result).toEqual(hunk.lines.map((line) => `<H>${line.text}</H>`));
47
+ });
48
+
49
+ test("passes old and new versions as multi-line blocks (not per line)", () => {
50
+ const hunk = firstHunk(
51
+ ["line1", "old-mid", "line3"],
52
+ ["line1", "new-mid", "line3"]
53
+ );
54
+ const calls: string[][] = [];
55
+ DiffRenderer.highlightHunkLines(hunk, recordingHighlighter(calls));
56
+
57
+ expect(calls).toHaveLength(2);
58
+ expect(calls).toContainEqual(["line1", "old-mid", "line3"]);
59
+ expect(calls).toContainEqual(["line1", "new-mid", "line3"]);
60
+ });
61
+
62
+ test("maps each diff line to the correct highlighted entry", () => {
63
+ const hunk = firstHunk(
64
+ ["keep", "drop1", "drop2", "tail"],
65
+ ["keep", "add1", "add2", "tail"]
66
+ );
67
+ const result = DiffRenderer.highlightHunkLines(hunk, tagHighlighter);
68
+
69
+ for (let i = 0; i < hunk.lines.length; i += 1) {
70
+ expect(result[i]).toBe(`<H>${hunk.lines[i]?.text}</H>`);
71
+ }
72
+ });
73
+
74
+ test("falls back to raw text when highlighter returns shorter array", () => {
75
+ const hunk = firstHunk(["a"], ["a", "b"]);
76
+ const truncating: DiffHighlighter = () => [];
77
+ const result = DiffRenderer.highlightHunkLines(hunk, truncating);
78
+
79
+ expect(result).toEqual(hunk.lines.map((line) => line.text));
80
+ });
81
+
82
+ test("skips highlighter calls when one side is empty", () => {
83
+ const hunk = firstHunk(["a", "b"], ["a", "b", "c", "d"]);
84
+ const calls: string[][] = [];
85
+ DiffRenderer.highlightHunkLines(hunk, recordingHighlighter(calls));
86
+
87
+ for (const call of calls) {
88
+ expect(call.length).toBeGreaterThan(0);
89
+ }
90
+ });
91
+ });
92
+
93
+ describe("DiffRenderer.render", () => {
94
+ const stubTheme = {
95
+ name: "pim-dark",
96
+ fg: (_color: string, text: string) => text,
97
+ } as unknown as Parameters<typeof DiffRenderer.render>[0]["theme"];
98
+
99
+ test("returns empty string for empty diff", () => {
100
+ const out = DiffRenderer.render({
101
+ toolDiff: { path: "foo.ts", hunks: [] },
102
+ theme: stubTheme,
103
+ });
104
+ expect(out).toBe("");
105
+ });
106
+
107
+ test("includes content for each diff line", () => {
108
+ const diff = DiffLines.buildToolDiff(
109
+ "foo.ts",
110
+ { lines: ["alpha", "beta", "gamma"], hasTrailingNewline: true },
111
+ { lines: ["alpha", "BETA", "gamma"], hasTrailingNewline: true },
112
+ 1
113
+ );
114
+
115
+ if (diff === undefined) {
116
+ throw new Error("expected diff");
117
+ }
118
+
119
+ const out = DiffRenderer.render({ toolDiff: diff, theme: stubTheme });
120
+
121
+ expect(out).toContain("alpha");
122
+ expect(out).toContain("beta");
123
+ expect(out).toContain("BETA");
124
+ expect(out).toContain("gamma");
125
+ });
126
+
127
+ test("emits emphasis bg for paired changed lines", () => {
128
+ const diff = DiffLines.buildToolDiff(
129
+ "foo.ts",
130
+ { lines: ["const x = 1;"], hasTrailingNewline: true },
131
+ { lines: ["const y = 2;"], hasTrailingNewline: true },
132
+ 1
133
+ );
134
+
135
+ if (diff === undefined) {
136
+ throw new Error("expected diff");
137
+ }
138
+
139
+ const out = DiffRenderer.render({ toolDiff: diff, theme: stubTheme });
140
+
141
+ expect(out).toContain("\x1b[48;2;26;81;47m");
142
+ expect(out).toContain("\x1b[48;2;100;35;35m");
143
+ });
144
+
145
+ test("does not render any EOF newline marker (EOF state is surfaced by callers, not the renderer)", () => {
146
+ const diff = DiffLines.buildToolDiff(
147
+ "foo.ts",
148
+ { lines: ["alpha"], hasTrailingNewline: false },
149
+ { lines: ["beta"], hasTrailingNewline: true },
150
+ 1
151
+ );
152
+
153
+ if (diff === undefined) {
154
+ throw new Error("expected diff");
155
+ }
156
+
157
+ const out = DiffRenderer.render({ toolDiff: diff, theme: stubTheme });
158
+ expect(out).not.toContain("No newline at end of file");
159
+ expect(out).not.toContain("Newline added");
160
+ });
161
+ });
162
+
163
+ describe("DiffRenderer.applyEmphasis", () => {
164
+ const lineBg = "\x1b[48;2;0;0;0m";
165
+ const emphBg = "\x1b[48;2;255;255;255m";
166
+
167
+ test("returns text unchanged when no ranges", () => {
168
+ expect(DiffRenderer.applyEmphasis("hello", [], lineBg, emphBg)).toBe(
169
+ "hello"
170
+ );
171
+ });
172
+
173
+ test("wraps an emphasized range with emph then line bg", () => {
174
+ const out = DiffRenderer.applyEmphasis(
175
+ "hello world",
176
+ [{ start: 6, end: 11 }],
177
+ lineBg,
178
+ emphBg
179
+ );
180
+ expect(out).toBe(`hello ${emphBg}world${lineBg}`);
181
+ });
182
+
183
+ test("counts visible chars only, ignoring ANSI escape sequences", () => {
184
+ const colored = `\x1b[31mhello\x1b[39m world`;
185
+ const out = DiffRenderer.applyEmphasis(
186
+ colored,
187
+ [{ start: 6, end: 11 }],
188
+ lineBg,
189
+ emphBg
190
+ );
191
+ expect(out).toBe(`\x1b[31mhello\x1b[39m ${emphBg}world${lineBg}`);
192
+ });
193
+
194
+ test("supports multiple non-overlapping ranges", () => {
195
+ const out = DiffRenderer.applyEmphasis(
196
+ "abcdef",
197
+ [
198
+ { start: 0, end: 2 },
199
+ { start: 4, end: 6 },
200
+ ],
201
+ lineBg,
202
+ emphBg
203
+ );
204
+ expect(out).toBe(`${emphBg}ab${lineBg}cd${emphBg}ef${lineBg}`);
205
+ });
206
+ });