@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.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLanguageFromPath,
|
|
3
|
+
highlightCode,
|
|
4
|
+
type Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type {
|
|
7
|
+
IntraLineRange,
|
|
8
|
+
ToolDiff,
|
|
9
|
+
ToolDiffHunk,
|
|
10
|
+
ToolDiffLine,
|
|
11
|
+
} from "./DiffLines";
|
|
12
|
+
|
|
13
|
+
export type DiffRenderOptions = {
|
|
14
|
+
readonly toolDiff: ToolDiff;
|
|
15
|
+
readonly theme: Theme;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type DiffHighlighter = (block: string) => readonly string[];
|
|
19
|
+
|
|
20
|
+
type DiffBackgrounds = {
|
|
21
|
+
readonly added: string;
|
|
22
|
+
readonly removed: string;
|
|
23
|
+
readonly addedEmph: string;
|
|
24
|
+
readonly removedEmph: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class DiffRenderer {
|
|
28
|
+
private static readonly TAB = " ";
|
|
29
|
+
private static readonly DARK_BG: DiffBackgrounds = {
|
|
30
|
+
added: "\x1b[48;2;13;40;24m",
|
|
31
|
+
removed: "\x1b[48;2;58;20;20m",
|
|
32
|
+
addedEmph: "\x1b[48;2;26;81;47m",
|
|
33
|
+
removedEmph: "\x1b[48;2;100;35;35m",
|
|
34
|
+
};
|
|
35
|
+
private static readonly LIGHT_BG: DiffBackgrounds = {
|
|
36
|
+
added: "\x1b[48;2;218;251;225m",
|
|
37
|
+
removed: "\x1b[48;2;255;235;233m",
|
|
38
|
+
addedEmph: "\x1b[48;2;172;238;187m",
|
|
39
|
+
removedEmph: "\x1b[48;2;255;195;188m",
|
|
40
|
+
};
|
|
41
|
+
private static readonly CLEAR_TO_EOL = "\x1b[K";
|
|
42
|
+
private static readonly BG_RESET = "\x1b[49m";
|
|
43
|
+
|
|
44
|
+
public static render(options: DiffRenderOptions): string {
|
|
45
|
+
const { toolDiff, theme } = options;
|
|
46
|
+
|
|
47
|
+
if (toolDiff.hunks.length === 0) {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lang = getLanguageFromPath(toolDiff.path);
|
|
52
|
+
const highlighter = DiffRenderer.makeHighlighter(lang);
|
|
53
|
+
const numberWidth = DiffRenderer.computeNumberWidth(toolDiff.hunks);
|
|
54
|
+
const backgrounds = DiffRenderer.backgroundsFor(theme);
|
|
55
|
+
const blocks: string[] = [];
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index < toolDiff.hunks.length; index += 1) {
|
|
58
|
+
const hunk = toolDiff.hunks[index];
|
|
59
|
+
|
|
60
|
+
if (hunk === undefined) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
blocks.push(
|
|
65
|
+
DiffRenderer.renderHunk(
|
|
66
|
+
hunk,
|
|
67
|
+
highlighter,
|
|
68
|
+
theme,
|
|
69
|
+
numberWidth,
|
|
70
|
+
backgrounds
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (index < toolDiff.hunks.length - 1) {
|
|
75
|
+
blocks.push(DiffRenderer.renderHunkSeparator(theme, numberWidth));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return blocks.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public static highlightHunkLines(
|
|
83
|
+
hunk: ToolDiffHunk,
|
|
84
|
+
highlighter: DiffHighlighter
|
|
85
|
+
): readonly string[] {
|
|
86
|
+
const oldIndices: (number | undefined)[] = [];
|
|
87
|
+
const newIndices: (number | undefined)[] = [];
|
|
88
|
+
const oldBlock: string[] = [];
|
|
89
|
+
const newBlock: string[] = [];
|
|
90
|
+
|
|
91
|
+
for (const line of hunk.lines) {
|
|
92
|
+
if (line.kind === "added") {
|
|
93
|
+
oldIndices.push(undefined);
|
|
94
|
+
newIndices.push(newBlock.length);
|
|
95
|
+
newBlock.push(line.text);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (line.kind === "removed") {
|
|
100
|
+
oldIndices.push(oldBlock.length);
|
|
101
|
+
newIndices.push(undefined);
|
|
102
|
+
oldBlock.push(line.text);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
oldIndices.push(oldBlock.length);
|
|
107
|
+
newIndices.push(newBlock.length);
|
|
108
|
+
oldBlock.push(line.text);
|
|
109
|
+
newBlock.push(line.text);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const oldHighlighted =
|
|
113
|
+
oldBlock.length === 0 ? [] : highlighter(oldBlock.join("\n"));
|
|
114
|
+
const newHighlighted =
|
|
115
|
+
newBlock.length === 0 ? [] : highlighter(newBlock.join("\n"));
|
|
116
|
+
|
|
117
|
+
return hunk.lines.map((line, idx) => {
|
|
118
|
+
if (line.kind === "removed") {
|
|
119
|
+
const blockIdx = oldIndices[idx];
|
|
120
|
+
return blockIdx === undefined
|
|
121
|
+
? line.text
|
|
122
|
+
: (oldHighlighted[blockIdx] ?? line.text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const blockIdx = newIndices[idx];
|
|
126
|
+
return blockIdx === undefined
|
|
127
|
+
? line.text
|
|
128
|
+
: (newHighlighted[blockIdx] ?? line.text);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private static makeHighlighter(lang: string | undefined): DiffHighlighter {
|
|
133
|
+
if (lang === undefined) {
|
|
134
|
+
return (block) => DiffRenderer.detab(block).split("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (block) => highlightCode(DiffRenderer.detab(block), lang);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private static detab(text: string): string {
|
|
141
|
+
return text.replace(/\t/g, DiffRenderer.TAB);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private static backgroundsFor(theme: Theme): DiffBackgrounds {
|
|
145
|
+
return DiffRenderer.isLightTheme(theme)
|
|
146
|
+
? DiffRenderer.LIGHT_BG
|
|
147
|
+
: DiffRenderer.DARK_BG;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private static isLightTheme(theme: Theme): boolean {
|
|
151
|
+
const name = theme.name?.toLowerCase() ?? "";
|
|
152
|
+
return name === "light" || name.includes("light");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private static computeNumberWidth(hunks: readonly ToolDiffHunk[]): number {
|
|
156
|
+
let max = 0;
|
|
157
|
+
|
|
158
|
+
for (const hunk of hunks) {
|
|
159
|
+
max = Math.max(max, hunk.oldStart + hunk.oldLines - 1);
|
|
160
|
+
max = Math.max(max, hunk.newStart + hunk.newLines - 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return Math.max(1, String(max).length);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private static renderHunk(
|
|
167
|
+
hunk: ToolDiffHunk,
|
|
168
|
+
highlighter: DiffHighlighter,
|
|
169
|
+
theme: Theme,
|
|
170
|
+
numberWidth: number,
|
|
171
|
+
backgrounds: DiffBackgrounds
|
|
172
|
+
): string {
|
|
173
|
+
const highlightedLines = DiffRenderer.highlightHunkLines(hunk, highlighter);
|
|
174
|
+
const rendered: string[] = [];
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < hunk.lines.length; i += 1) {
|
|
177
|
+
const line = hunk.lines[i];
|
|
178
|
+
const content = highlightedLines[i];
|
|
179
|
+
|
|
180
|
+
if (line === undefined || content === undefined) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
rendered.push(
|
|
185
|
+
DiffRenderer.renderLine(line, content, theme, numberWidth, backgrounds)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return rendered.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private static renderLine(
|
|
193
|
+
line: ToolDiffLine,
|
|
194
|
+
content: string,
|
|
195
|
+
theme: Theme,
|
|
196
|
+
numberWidth: number,
|
|
197
|
+
backgrounds: DiffBackgrounds
|
|
198
|
+
): string {
|
|
199
|
+
const prefix = DiffRenderer.formatPrefix(line, theme, numberWidth);
|
|
200
|
+
const emphasized = DiffRenderer.applyLineEmphasis(
|
|
201
|
+
line,
|
|
202
|
+
content,
|
|
203
|
+
backgrounds
|
|
204
|
+
);
|
|
205
|
+
return DiffRenderer.applyBackground(
|
|
206
|
+
line.kind,
|
|
207
|
+
` ${prefix}${emphasized}`,
|
|
208
|
+
backgrounds
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private static applyLineEmphasis(
|
|
213
|
+
line: ToolDiffLine,
|
|
214
|
+
content: string,
|
|
215
|
+
backgrounds: DiffBackgrounds
|
|
216
|
+
): string {
|
|
217
|
+
const ranges = line.emphasis;
|
|
218
|
+
|
|
219
|
+
if (ranges === undefined || ranges.length === 0) {
|
|
220
|
+
return content;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (line.kind === "added") {
|
|
224
|
+
return DiffRenderer.applyEmphasis(
|
|
225
|
+
content,
|
|
226
|
+
ranges,
|
|
227
|
+
backgrounds.added,
|
|
228
|
+
backgrounds.addedEmph
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (line.kind === "removed") {
|
|
233
|
+
return DiffRenderer.applyEmphasis(
|
|
234
|
+
content,
|
|
235
|
+
ranges,
|
|
236
|
+
backgrounds.removed,
|
|
237
|
+
backgrounds.removedEmph
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return content;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public static applyEmphasis(
|
|
245
|
+
text: string,
|
|
246
|
+
ranges: readonly IntraLineRange[],
|
|
247
|
+
lineBg: string,
|
|
248
|
+
emphBg: string
|
|
249
|
+
): string {
|
|
250
|
+
if (ranges.length === 0) {
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const starts = new Set<number>();
|
|
255
|
+
const ends = new Set<number>();
|
|
256
|
+
|
|
257
|
+
for (const range of ranges) {
|
|
258
|
+
if (range.end > range.start) {
|
|
259
|
+
starts.add(range.start);
|
|
260
|
+
ends.add(range.end);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (starts.size === 0) {
|
|
265
|
+
return text;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let result = "";
|
|
269
|
+
let visiblePos = 0;
|
|
270
|
+
let i = 0;
|
|
271
|
+
let segStart = 0;
|
|
272
|
+
|
|
273
|
+
while (i < text.length) {
|
|
274
|
+
if (text.charCodeAt(i) === 0x1b && text[i + 1] === "[") {
|
|
275
|
+
const escEnd = text.indexOf("m", i + 2);
|
|
276
|
+
|
|
277
|
+
if (escEnd === -1) {
|
|
278
|
+
return result + text.slice(segStart);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
i = escEnd + 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (ends.has(visiblePos) || starts.has(visiblePos)) {
|
|
286
|
+
if (i > segStart) {
|
|
287
|
+
result += text.slice(segStart, i);
|
|
288
|
+
}
|
|
289
|
+
if (ends.has(visiblePos)) {
|
|
290
|
+
result += lineBg;
|
|
291
|
+
}
|
|
292
|
+
if (starts.has(visiblePos)) {
|
|
293
|
+
result += emphBg;
|
|
294
|
+
}
|
|
295
|
+
segStart = i;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
visiblePos += 1;
|
|
299
|
+
i += 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (i > segStart) {
|
|
303
|
+
result += text.slice(segStart, i);
|
|
304
|
+
}
|
|
305
|
+
if (ends.has(visiblePos)) {
|
|
306
|
+
result += lineBg;
|
|
307
|
+
}
|
|
308
|
+
if (starts.has(visiblePos)) {
|
|
309
|
+
result += emphBg;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private static applyBackground(
|
|
316
|
+
kind: ToolDiffLine["kind"],
|
|
317
|
+
text: string,
|
|
318
|
+
backgrounds: DiffBackgrounds
|
|
319
|
+
): string {
|
|
320
|
+
if (kind === "added") {
|
|
321
|
+
return `${backgrounds.added}${text}${DiffRenderer.CLEAR_TO_EOL}${DiffRenderer.BG_RESET}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (kind === "removed") {
|
|
325
|
+
return `${backgrounds.removed}${text}${DiffRenderer.CLEAR_TO_EOL}${DiffRenderer.BG_RESET}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return text;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private static formatPrefix(
|
|
332
|
+
line: ToolDiffLine,
|
|
333
|
+
theme: Theme,
|
|
334
|
+
numberWidth: number
|
|
335
|
+
): string {
|
|
336
|
+
const numLabel = DiffRenderer.formatLineNumber(
|
|
337
|
+
DiffRenderer.relevantLineNumber(line),
|
|
338
|
+
numberWidth
|
|
339
|
+
);
|
|
340
|
+
const sign = DiffRenderer.signFor(line.kind);
|
|
341
|
+
const gutter = `${numLabel} ${sign} `;
|
|
342
|
+
|
|
343
|
+
if (line.kind === "added") {
|
|
344
|
+
return theme.fg("toolDiffAdded", gutter);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (line.kind === "removed") {
|
|
348
|
+
return theme.fg("toolDiffRemoved", gutter);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return theme.fg("toolDiffContext", gutter);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private static relevantLineNumber(line: ToolDiffLine): number | undefined {
|
|
355
|
+
if (line.kind === "added") {
|
|
356
|
+
return line.newLine;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (line.kind === "removed") {
|
|
360
|
+
return line.oldLine;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return line.newLine ?? line.oldLine;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private static signFor(kind: ToolDiffLine["kind"]): string {
|
|
367
|
+
if (kind === "added") {
|
|
368
|
+
return "+";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (kind === "removed") {
|
|
372
|
+
return "−";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return " ";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private static formatLineNumber(
|
|
379
|
+
value: number | undefined,
|
|
380
|
+
width: number
|
|
381
|
+
): string {
|
|
382
|
+
if (value === undefined) {
|
|
383
|
+
return " ".repeat(width);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return String(value).padStart(width, " ");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private static renderHunkSeparator(
|
|
390
|
+
theme: Theme,
|
|
391
|
+
numberWidth: number
|
|
392
|
+
): string {
|
|
393
|
+
const filler = " ".repeat(numberWidth);
|
|
394
|
+
return theme.fg("toolDiffContext", ` ${filler} ⋯`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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 "./DiffLines";
|
|
8
|
+
import { DiffRenderer } from "./DiffRenderer";
|
|
9
|
+
import { Paths } from "./Paths";
|
|
10
|
+
import { type MarkerStatus, Renderer } from "./Renderer";
|
|
11
|
+
|
|
12
|
+
export type DiffStats = {
|
|
13
|
+
readonly added: number;
|
|
14
|
+
readonly removed: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DiffRenderState = {
|
|
18
|
+
titleComponent?: Component;
|
|
19
|
+
path?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class DiffView {
|
|
23
|
+
public static countStats(diff: ToolDiff | undefined): DiffStats {
|
|
24
|
+
if (!diff) {
|
|
25
|
+
return { added: 0, removed: 0 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let added = 0;
|
|
29
|
+
let removed = 0;
|
|
30
|
+
|
|
31
|
+
for (const hunk of diff.hunks) {
|
|
32
|
+
for (const line of hunk.lines) {
|
|
33
|
+
if (line.kind === "added") {
|
|
34
|
+
added += 1;
|
|
35
|
+
} else if (line.kind === "removed") {
|
|
36
|
+
removed += 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { added, removed };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public static formatStats(stats: DiffStats, theme: Theme): string {
|
|
45
|
+
const parts: string[] = [];
|
|
46
|
+
|
|
47
|
+
if (stats.added > 0) {
|
|
48
|
+
parts.push(theme.fg("toolDiffAdded", `+${stats.added}`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (stats.removed > 0) {
|
|
52
|
+
parts.push(theme.fg("toolDiffRemoved", `-${stats.removed}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parts.join("/");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public static buildTitle(args: {
|
|
59
|
+
readonly label: string;
|
|
60
|
+
readonly path: string;
|
|
61
|
+
readonly stats: DiffStats;
|
|
62
|
+
readonly theme: Theme;
|
|
63
|
+
readonly markerColor: MarkerStatus;
|
|
64
|
+
readonly lastComponent: Component | undefined;
|
|
65
|
+
}): Component {
|
|
66
|
+
const { label, path, stats, theme, markerColor, lastComponent } = args;
|
|
67
|
+
const statsText = DiffView.formatStats(stats, theme);
|
|
68
|
+
|
|
69
|
+
return Renderer.renderToolCallTitle({
|
|
70
|
+
label,
|
|
71
|
+
title: statsText ? `${path} ${statsText}` : path,
|
|
72
|
+
theme,
|
|
73
|
+
context: {
|
|
74
|
+
lastComponent,
|
|
75
|
+
isPartial: markerColor === "warning",
|
|
76
|
+
isError: markerColor === "error",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public static buildBlock(args: {
|
|
82
|
+
readonly diff: ToolDiff;
|
|
83
|
+
readonly theme: Theme;
|
|
84
|
+
readonly lastComponent: Component | undefined;
|
|
85
|
+
}): Container {
|
|
86
|
+
const { diff, theme, lastComponent } = args;
|
|
87
|
+
const container =
|
|
88
|
+
(lastComponent as Container | undefined) ?? new Container();
|
|
89
|
+
container.clear();
|
|
90
|
+
|
|
91
|
+
const body = DiffRenderer.render({ toolDiff: diff, theme });
|
|
92
|
+
|
|
93
|
+
if (!body) {
|
|
94
|
+
return container;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
container.addChild(
|
|
98
|
+
Renderer.makePrefixedBlock({
|
|
99
|
+
text: body,
|
|
100
|
+
theme,
|
|
101
|
+
prefix: Renderer.TIGHT_PREFIX,
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
container.invalidate();
|
|
106
|
+
return container;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public static renderDiffCall(args: {
|
|
110
|
+
readonly label: string;
|
|
111
|
+
readonly rawPath: string | undefined;
|
|
112
|
+
readonly theme: Theme;
|
|
113
|
+
readonly context: {
|
|
114
|
+
readonly state: DiffRenderState;
|
|
115
|
+
readonly cwd: string;
|
|
116
|
+
readonly isPartial: boolean;
|
|
117
|
+
readonly isError: boolean;
|
|
118
|
+
readonly lastComponent: Component | undefined;
|
|
119
|
+
};
|
|
120
|
+
}): Component {
|
|
121
|
+
const { label, rawPath, theme, context } = args;
|
|
122
|
+
const state = context.state;
|
|
123
|
+
const display = Paths.titleOr(rawPath, context.cwd);
|
|
124
|
+
state.path = display;
|
|
125
|
+
const markerColor = Renderer.markerColorFor(
|
|
126
|
+
Boolean(context.isPartial),
|
|
127
|
+
Boolean(context.isError)
|
|
128
|
+
);
|
|
129
|
+
const text = DiffView.buildTitle({
|
|
130
|
+
label,
|
|
131
|
+
path: display,
|
|
132
|
+
stats: { added: 0, removed: 0 },
|
|
133
|
+
theme,
|
|
134
|
+
markerColor,
|
|
135
|
+
lastComponent: context.lastComponent,
|
|
136
|
+
});
|
|
137
|
+
state.titleComponent = text;
|
|
138
|
+
return text;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public static renderDiffResult(args: {
|
|
142
|
+
readonly label: string;
|
|
143
|
+
readonly result: AgentToolResult<unknown>;
|
|
144
|
+
readonly options: ToolRenderResultOptions;
|
|
145
|
+
readonly theme: Theme;
|
|
146
|
+
readonly context: {
|
|
147
|
+
readonly state: DiffRenderState;
|
|
148
|
+
readonly isError: boolean;
|
|
149
|
+
readonly lastComponent: Component | undefined;
|
|
150
|
+
};
|
|
151
|
+
readonly previewLines: number;
|
|
152
|
+
}): Component {
|
|
153
|
+
const { label, result, options, theme, context, previewLines } = args;
|
|
154
|
+
const state = context.state;
|
|
155
|
+
const fallback =
|
|
156
|
+
(context.lastComponent as Container | undefined) ?? new Container();
|
|
157
|
+
|
|
158
|
+
if (options.isPartial) {
|
|
159
|
+
fallback.clear();
|
|
160
|
+
return fallback;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (context.isError) {
|
|
164
|
+
return Renderer.renderBorderedResult({
|
|
165
|
+
result,
|
|
166
|
+
options,
|
|
167
|
+
theme,
|
|
168
|
+
context: { ...context, isPartial: false },
|
|
169
|
+
previewLines,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const details = result.details as { readonly diff?: ToolDiff } | undefined;
|
|
174
|
+
const diff = details?.diff;
|
|
175
|
+
const stats = DiffView.countStats(diff);
|
|
176
|
+
|
|
177
|
+
if (state.titleComponent && state.path !== undefined) {
|
|
178
|
+
DiffView.buildTitle({
|
|
179
|
+
label,
|
|
180
|
+
path: state.path,
|
|
181
|
+
stats,
|
|
182
|
+
theme,
|
|
183
|
+
markerColor: Renderer.markerColorFor(false, false),
|
|
184
|
+
lastComponent: state.titleComponent,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!diff) {
|
|
189
|
+
fallback.clear();
|
|
190
|
+
return fallback;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return DiffView.buildBlock({
|
|
194
|
+
diff,
|
|
195
|
+
theme,
|
|
196
|
+
lastComponent: context.lastComponent,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { EditMatcher } from "./EditMatcher";
|
|
3
|
+
|
|
4
|
+
const replace = (
|
|
5
|
+
content: string,
|
|
6
|
+
oldString: string,
|
|
7
|
+
newString: string,
|
|
8
|
+
replaceAll = false
|
|
9
|
+
): string => {
|
|
10
|
+
const resolved = EditMatcher.resolve(content, oldString, replaceAll);
|
|
11
|
+
const ranges = "ranges" in resolved ? resolved.ranges : [resolved.range];
|
|
12
|
+
return EditMatcher.applyAll(
|
|
13
|
+
content,
|
|
14
|
+
ranges.map((range) => ({ range, newString }))
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("EditMatcher", () => {
|
|
19
|
+
test("resolves exact matches", () => {
|
|
20
|
+
expect(replace("alpha\nbeta\ngamma", "beta", "delta")).toBe(
|
|
21
|
+
"alpha\ndelta\ngamma"
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("uses lineTrimmed fallback", () => {
|
|
26
|
+
expect(replace("alpha\n beta\ngamma", "beta ", "delta")).toBe(
|
|
27
|
+
"alpha\ndelta\ngamma"
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("uses whitespaceNormalized fallback", () => {
|
|
32
|
+
expect(replace("alpha\nfoo bar\ngamma", "foo bar", "baz")).toBe(
|
|
33
|
+
"alpha\nbaz\ngamma"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("uses indentationFlexible fallback", () => {
|
|
38
|
+
const content = "root\n if (ok) {\n run()\n }\nend";
|
|
39
|
+
const oldString = "if (ok) {\n run()\n}";
|
|
40
|
+
expect(replace(content, oldString, "done()")).toBe("root\ndone()\nend");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("uses escapeNormalized fallback", () => {
|
|
44
|
+
expect(replace("alpha\nbeta\ngamma", "beta\\ngamma", "delta")).toBe(
|
|
45
|
+
"alpha\ndelta"
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("uses trimmedBoundary fallback", () => {
|
|
50
|
+
expect(replace("alpha\nbeta\ngamma", "\n beta \n", "delta")).toBe(
|
|
51
|
+
"alpha\ndelta\ngamma"
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("uses unicodeNormalized fallback", () => {
|
|
56
|
+
expect(replace("say “hello” now", 'say "hello" now', "done")).toBe("done");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("uses blockAnchor fallback with same-line-count constraint", () => {
|
|
60
|
+
const content = [
|
|
61
|
+
"start",
|
|
62
|
+
"actual middle",
|
|
63
|
+
"end",
|
|
64
|
+
"start",
|
|
65
|
+
"one",
|
|
66
|
+
"two",
|
|
67
|
+
"three",
|
|
68
|
+
"end",
|
|
69
|
+
].join("\n");
|
|
70
|
+
|
|
71
|
+
expect(replace(content, "start\nexpected middle\nend", "done")).toBe(
|
|
72
|
+
["done", "start", "one", "two", "three", "end"].join("\n")
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("blockAnchor matches 3-line region with drifted middle", () => {
|
|
77
|
+
const content = ["start", "drifted middle", "end"].join("\n");
|
|
78
|
+
expect(replace(content, "start\nexpected middle\nend", "done")).toBe(
|
|
79
|
+
"done"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("uses contextAware fallback", () => {
|
|
84
|
+
const content = "start\nsame\nactual\nend";
|
|
85
|
+
expect(replace(content, "start\nsame\nexpected\nend", "done")).toBe("done");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("replaceAll returns every occurrence", () => {
|
|
89
|
+
expect(replace("foo\nbar\nfoo", "foo", "baz", true)).toBe("baz\nbar\nbaz");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("throws multiple matches without replaceAll", () => {
|
|
93
|
+
expect(() => EditMatcher.resolve("foo\nbar\nfoo", "foo")).toThrow(
|
|
94
|
+
/matched multiple regions/
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("not found includes closest regions above threshold", () => {
|
|
99
|
+
const closest = EditMatcher.findClosestRegions(
|
|
100
|
+
"alpha\nbeta\ngamma",
|
|
101
|
+
"betx"
|
|
102
|
+
);
|
|
103
|
+
expect(closest[0]?.startLine).toBe(2);
|
|
104
|
+
expect(closest[0]?.similarity).toBeGreaterThan(0.5);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("not found returns no regions below threshold", () => {
|
|
108
|
+
const closest = EditMatcher.findClosestRegions("aaaa\nbbbb", "zzzz");
|
|
109
|
+
expect(closest).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("escape-drift guard rejects new escape sequences after fuzzy match", () => {
|
|
113
|
+
const resolved = EditMatcher.resolve(" beta", "beta ");
|
|
114
|
+
const range = "range" in resolved ? resolved.range : resolved.ranges[0]!;
|
|
115
|
+
expect(() =>
|
|
116
|
+
EditMatcher.assertNoEscapeDrift(
|
|
117
|
+
resolved.strategy,
|
|
118
|
+
"new\\nvalue",
|
|
119
|
+
" beta".slice(range[0], range[1])
|
|
120
|
+
)
|
|
121
|
+
).toThrow(/newString contains literal escape text/);
|
|
122
|
+
});
|
|
123
|
+
});
|