@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,77 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const FINAL_WIDGET_ID = "pim-working-finished";
|
|
7
|
+
|
|
8
|
+
export function formatElapsed(ms: number): string {
|
|
9
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
10
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
11
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
12
|
+
const seconds = totalSeconds % 60;
|
|
13
|
+
return hours > 0
|
|
14
|
+
? `${hours}h ${minutes}m ${seconds}s`
|
|
15
|
+
: minutes > 0
|
|
16
|
+
? `${minutes}m ${seconds}s`
|
|
17
|
+
: `${seconds}s`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function (pi: ExtensionAPI): void {
|
|
21
|
+
let startedAt = 0;
|
|
22
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
23
|
+
|
|
24
|
+
const stopTimer = (): void => {
|
|
25
|
+
if (timer !== undefined) {
|
|
26
|
+
clearInterval(timer);
|
|
27
|
+
timer = undefined;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const setWorkingMessage = (ctx: ExtensionContext): void => {
|
|
32
|
+
ctx.ui.setWorkingMessage(
|
|
33
|
+
`Clanking… ${formatElapsed(Date.now() - startedAt)}`
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const setWorkingIndicator = (ctx: ExtensionContext): void => {
|
|
38
|
+
ctx.ui.setWorkingIndicator({
|
|
39
|
+
frames: ["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"].map((frame) =>
|
|
40
|
+
ctx.ui.theme.fg("accent", frame)
|
|
41
|
+
),
|
|
42
|
+
intervalMs: 80,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
47
|
+
if (!ctx.hasUI) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
startedAt = Date.now();
|
|
52
|
+
ctx.ui.setWidget(FINAL_WIDGET_ID, undefined);
|
|
53
|
+
setWorkingIndicator(ctx);
|
|
54
|
+
setWorkingMessage(ctx);
|
|
55
|
+
stopTimer();
|
|
56
|
+
timer = setInterval(() => setWorkingMessage(ctx), 1000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
60
|
+
stopTimer();
|
|
61
|
+
if (!ctx.hasUI) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Trailing newline to separate from other widgets right below; newline will
|
|
65
|
+
// not show up when this widget is the only one shown
|
|
66
|
+
const message = `⣿ Clanked for ${formatElapsed(Date.now() - startedAt)}\n`;
|
|
67
|
+
ctx.ui.setWidget(FINAL_WIDGET_ID, [ctx.ui.theme.fg("muted", message)]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
71
|
+
stopTimer();
|
|
72
|
+
if (!ctx.hasUI) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
ctx.ui.setWidget(FINAL_WIDGET_ID, undefined);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type DiffRenderState, DiffView } from "../../shared/DiffView";
|
|
3
|
+
import { Paths } from "../../shared/Paths";
|
|
4
|
+
import { Tools } from "../../shared/Tools";
|
|
5
|
+
import { type WriteInput, writeSchema } from "./schema";
|
|
6
|
+
import { writeContent, type WriteOutcome } from "./write";
|
|
7
|
+
|
|
8
|
+
const ERROR_PREVIEW_LINES = 12;
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI): void {
|
|
11
|
+
Tools.register(pi, {
|
|
12
|
+
name: "write",
|
|
13
|
+
label: "write",
|
|
14
|
+
description:
|
|
15
|
+
"Create or overwrite UTF-8 text files. Use write only for new files or full rewrites - prefer edit for changes to existing files.",
|
|
16
|
+
parameters: writeSchema,
|
|
17
|
+
renderShell: "self",
|
|
18
|
+
executionMode: "sequential",
|
|
19
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
20
|
+
const { path, content } = params as WriteInput;
|
|
21
|
+
|
|
22
|
+
if (signal?.aborted) {
|
|
23
|
+
throw new Error("Write aborted before execution.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const absolutePath = Paths.resolve(path, ctx.cwd);
|
|
27
|
+
const outcome = await writeContent(absolutePath, content);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: formatSummary(path, outcome) }],
|
|
31
|
+
details: outcome,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
renderCall(args, theme, context) {
|
|
35
|
+
const rawPath = typeof args?.path === "string" ? args.path : undefined;
|
|
36
|
+
return DiffView.renderDiffCall({
|
|
37
|
+
label: "Write",
|
|
38
|
+
rawPath,
|
|
39
|
+
theme,
|
|
40
|
+
context: context as typeof context & { state: DiffRenderState },
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
renderResult(result, options, theme, context) {
|
|
44
|
+
return DiffView.renderDiffResult({
|
|
45
|
+
label: "Write",
|
|
46
|
+
result,
|
|
47
|
+
options,
|
|
48
|
+
theme,
|
|
49
|
+
context: context as typeof context & { state: DiffRenderState },
|
|
50
|
+
previewLines: ERROR_PREVIEW_LINES,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatSummary(path: string, outcome: WriteOutcome): string {
|
|
57
|
+
const verb = outcome.created ? "Created" : "Wrote";
|
|
58
|
+
const eofNote =
|
|
59
|
+
outcome.trailingNewlineChange === undefined
|
|
60
|
+
? ""
|
|
61
|
+
: ` Trailing newline ${outcome.trailingNewlineChange}.`;
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
outcome.diff === undefined &&
|
|
65
|
+
!outcome.created &&
|
|
66
|
+
outcome.diffSkipped === undefined
|
|
67
|
+
) {
|
|
68
|
+
return `Wrote ${outcome.bytesWritten} bytes to ${path} (no content changes).${eofNote}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (outcome.diffSkipped !== undefined) {
|
|
72
|
+
return `${verb} ${outcome.bytesWritten} bytes at ${path} (diff omitted: file exceeds ${outcome.diffSkipped.thresholdBytes}-byte render cap).${eofNote}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return `${verb} ${outcome.bytesWritten} bytes at ${path}.${eofNote}`;
|
|
76
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { DiffLines } from "../../shared/DiffLines";
|
|
4
|
+
import { DiffView } from "../../shared/DiffView";
|
|
5
|
+
|
|
6
|
+
const stubTheme = {
|
|
7
|
+
fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
|
|
8
|
+
} as unknown as Theme;
|
|
9
|
+
|
|
10
|
+
describe("DiffView.countStats", () => {
|
|
11
|
+
test("returns zeros when diff is undefined", () => {
|
|
12
|
+
expect(DiffView.countStats(undefined)).toEqual({ added: 0, removed: 0 });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("counts added and removed lines across hunks", () => {
|
|
16
|
+
const diff = DiffLines.buildToolDiff(
|
|
17
|
+
"/tmp/x.ts",
|
|
18
|
+
{
|
|
19
|
+
lines: ["alpha", "beta", "gamma", "delta"],
|
|
20
|
+
hasTrailingNewline: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
lines: ["alpha", "BETA", "gamma", "DELTA"],
|
|
24
|
+
hasTrailingNewline: true,
|
|
25
|
+
},
|
|
26
|
+
0
|
|
27
|
+
);
|
|
28
|
+
expect(DiffView.countStats(diff)).toEqual({ added: 2, removed: 2 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("counts a brand-new file as all added", () => {
|
|
32
|
+
const diff = DiffLines.buildToolDiff(
|
|
33
|
+
"/tmp/new.ts",
|
|
34
|
+
{ lines: [], hasTrailingNewline: false },
|
|
35
|
+
{ lines: ["one", "two", "three"], hasTrailingNewline: true },
|
|
36
|
+
0
|
|
37
|
+
);
|
|
38
|
+
expect(DiffView.countStats(diff)).toEqual({ added: 3, removed: 0 });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("DiffView.formatStats", () => {
|
|
43
|
+
test("emits both segments separated by a slash", () => {
|
|
44
|
+
expect(DiffView.formatStats({ added: 5, removed: 2 }, stubTheme)).toBe(
|
|
45
|
+
"<toolDiffAdded>+5</toolDiffAdded>/<toolDiffRemoved>-2</toolDiffRemoved>"
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("omits the removed segment when zero", () => {
|
|
50
|
+
expect(DiffView.formatStats({ added: 5, removed: 0 }, stubTheme)).toBe(
|
|
51
|
+
"<toolDiffAdded>+5</toolDiffAdded>"
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("omits the added segment when zero", () => {
|
|
56
|
+
expect(DiffView.formatStats({ added: 0, removed: 2 }, stubTheme)).toBe(
|
|
57
|
+
"<toolDiffRemoved>-2</toolDiffRemoved>"
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns empty string when both are zero", () => {
|
|
62
|
+
expect(DiffView.formatStats({ added: 0, removed: 0 }, stubTheme)).toBe("");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const writeSchema = Type.Object({
|
|
4
|
+
path: Type.String({
|
|
5
|
+
description:
|
|
6
|
+
"Absolute or relative path to file (resolved against cwd). Parent directories are created if missing.",
|
|
7
|
+
}),
|
|
8
|
+
content: Type.String({
|
|
9
|
+
description:
|
|
10
|
+
"UTF-8 text written verbatim. Include a trailing newline if needed.",
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type WriteInput = Static<typeof writeSchema>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { writeContent } from "./write";
|
|
6
|
+
|
|
7
|
+
const tempRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
const tempRoot = async (): Promise<string> => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "pim-write-tool-"));
|
|
11
|
+
tempRoots.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await Promise.all(
|
|
17
|
+
tempRoots.map((root) => rm(root, { force: true, recursive: true }))
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("writeContent", () => {
|
|
22
|
+
test("creates a new file and reports an all-added diff", async () => {
|
|
23
|
+
const root = await tempRoot();
|
|
24
|
+
const path = join(root, "fresh.ts");
|
|
25
|
+
|
|
26
|
+
const outcome = await writeContent(path, "alpha\nbeta\n");
|
|
27
|
+
|
|
28
|
+
expect(outcome.created).toBe(true);
|
|
29
|
+
expect(outcome.bytesWritten).toBe(11);
|
|
30
|
+
expect(await readFile(path, "utf8")).toBe("alpha\nbeta\n");
|
|
31
|
+
expect(outcome.diff).toBeDefined();
|
|
32
|
+
const lines = outcome.diff?.hunks[0]?.lines ?? [];
|
|
33
|
+
expect(lines.map((line) => line.kind)).toEqual(["added", "added"]);
|
|
34
|
+
expect(lines.map((line) => line.text)).toEqual(["alpha", "beta"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("creates parent directories when missing", async () => {
|
|
38
|
+
const root = await tempRoot();
|
|
39
|
+
const path = join(root, "deep", "nest", "file.txt");
|
|
40
|
+
|
|
41
|
+
const outcome = await writeContent(path, "hello");
|
|
42
|
+
|
|
43
|
+
expect(outcome.created).toBe(true);
|
|
44
|
+
expect(await readFile(path, "utf8")).toBe("hello");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("emits no diff when content is unchanged", async () => {
|
|
48
|
+
const root = await tempRoot();
|
|
49
|
+
const path = join(root, "same.txt");
|
|
50
|
+
await writeFile(path, "alpha\nbeta\n", "utf8");
|
|
51
|
+
|
|
52
|
+
const outcome = await writeContent(path, "alpha\nbeta\n");
|
|
53
|
+
|
|
54
|
+
expect(outcome.created).toBe(false);
|
|
55
|
+
expect(outcome.diff).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reports a trailing-newline removal even when content matches and produces no diff hunks", async () => {
|
|
59
|
+
const root = await tempRoot();
|
|
60
|
+
const path = join(root, "eof.txt");
|
|
61
|
+
await writeFile(path, "alpha\n", "utf8");
|
|
62
|
+
|
|
63
|
+
const outcome = await writeContent(path, "alpha");
|
|
64
|
+
|
|
65
|
+
expect(outcome.created).toBe(false);
|
|
66
|
+
expect(outcome.diff).toBeUndefined();
|
|
67
|
+
expect(outcome.trailingNewlineChange).toBe("removed");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("reports a trailing-newline addition", async () => {
|
|
71
|
+
const root = await tempRoot();
|
|
72
|
+
const path = join(root, "eof2.txt");
|
|
73
|
+
await writeFile(path, "alpha", "utf8");
|
|
74
|
+
|
|
75
|
+
const outcome = await writeContent(path, "alpha\n");
|
|
76
|
+
|
|
77
|
+
expect(outcome.created).toBe(false);
|
|
78
|
+
expect(outcome.trailingNewlineChange).toBe("added");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("does not report trailing-newline change for newly created files", async () => {
|
|
82
|
+
const root = await tempRoot();
|
|
83
|
+
const path = join(root, "fresh.txt");
|
|
84
|
+
|
|
85
|
+
const outcome = await writeContent(path, "alpha");
|
|
86
|
+
|
|
87
|
+
expect(outcome.created).toBe(true);
|
|
88
|
+
expect(outcome.trailingNewlineChange).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("emits a diff capturing modified lines", async () => {
|
|
92
|
+
const root = await tempRoot();
|
|
93
|
+
const path = join(root, "edit.txt");
|
|
94
|
+
await writeFile(path, "alpha\nbeta\ngamma\n", "utf8");
|
|
95
|
+
|
|
96
|
+
const outcome = await writeContent(path, "alpha\nBETA\ngamma\n");
|
|
97
|
+
|
|
98
|
+
expect(outcome.created).toBe(false);
|
|
99
|
+
const lines = outcome.diff?.hunks[0]?.lines ?? [];
|
|
100
|
+
const kinds = lines.map((line) => line.kind);
|
|
101
|
+
expect(kinds).toContain("removed");
|
|
102
|
+
expect(kinds).toContain("added");
|
|
103
|
+
const removed = lines.find((line) => line.kind === "removed");
|
|
104
|
+
const added = lines.find((line) => line.kind === "added");
|
|
105
|
+
expect(removed?.text).toBe("beta");
|
|
106
|
+
expect(added?.text).toBe("BETA");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { DiffLines, type ToolDiff } from "../../shared/DiffLines";
|
|
2
|
+
import { Fs } from "../../shared/Fs";
|
|
3
|
+
|
|
4
|
+
const CONTEXT_LINES = 3;
|
|
5
|
+
|
|
6
|
+
// Diff.diffLines is O(n*d); skip rendering for multi-MB writes. The write
|
|
7
|
+
// itself still happens — only the diff is omitted.
|
|
8
|
+
const MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export type WriteOutcome = {
|
|
11
|
+
readonly bytesWritten: number;
|
|
12
|
+
readonly created: boolean;
|
|
13
|
+
readonly diff?: ToolDiff;
|
|
14
|
+
readonly diffSkipped?: {
|
|
15
|
+
readonly reason: "size";
|
|
16
|
+
readonly thresholdBytes: number;
|
|
17
|
+
readonly comparedBytes: number;
|
|
18
|
+
};
|
|
19
|
+
// Surfaced in the result text since the renderer doesn't visualize EOF state.
|
|
20
|
+
readonly trailingNewlineChange?: "added" | "removed";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function writeContent(
|
|
24
|
+
absolutePath: string,
|
|
25
|
+
content: string
|
|
26
|
+
): Promise<WriteOutcome> {
|
|
27
|
+
const prior = await readPriorContent(absolutePath);
|
|
28
|
+
const bytesWritten = Buffer.byteLength(content, "utf8");
|
|
29
|
+
|
|
30
|
+
if (prior === content) {
|
|
31
|
+
return { bytesWritten, created: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await Fs.writeAtomic(absolutePath, content);
|
|
35
|
+
|
|
36
|
+
const created = prior === undefined;
|
|
37
|
+
const priorBytes = prior === undefined ? 0 : Buffer.byteLength(prior, "utf8");
|
|
38
|
+
const comparedBytes = Math.max(priorBytes, bytesWritten);
|
|
39
|
+
|
|
40
|
+
const oldSide =
|
|
41
|
+
prior === undefined
|
|
42
|
+
? { lines: [], hasTrailingNewline: false }
|
|
43
|
+
: DiffLines.fromText(prior);
|
|
44
|
+
const newSide = DiffLines.fromText(content);
|
|
45
|
+
const trailingNewlineChange = diffEofChange(oldSide, newSide, created);
|
|
46
|
+
|
|
47
|
+
if (comparedBytes > MAX_DIFF_BYTES) {
|
|
48
|
+
return {
|
|
49
|
+
bytesWritten,
|
|
50
|
+
created,
|
|
51
|
+
diffSkipped: {
|
|
52
|
+
reason: "size",
|
|
53
|
+
thresholdBytes: MAX_DIFF_BYTES,
|
|
54
|
+
comparedBytes,
|
|
55
|
+
},
|
|
56
|
+
...(trailingNewlineChange === undefined ? {} : { trailingNewlineChange }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const diff = DiffLines.buildToolDiff(
|
|
61
|
+
absolutePath,
|
|
62
|
+
oldSide,
|
|
63
|
+
newSide,
|
|
64
|
+
CONTEXT_LINES
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
bytesWritten,
|
|
69
|
+
created,
|
|
70
|
+
...(diff === undefined ? {} : { diff }),
|
|
71
|
+
...(trailingNewlineChange === undefined ? {} : { trailingNewlineChange }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function diffEofChange(
|
|
76
|
+
oldSide: { readonly hasTrailingNewline: boolean },
|
|
77
|
+
newSide: { readonly hasTrailingNewline: boolean },
|
|
78
|
+
created: boolean
|
|
79
|
+
): "added" | "removed" | undefined {
|
|
80
|
+
if (created || oldSide.hasTrailingNewline === newSide.hasTrailingNewline) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return newSide.hasTrailingNewline ? "added" : "removed";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readPriorContent(
|
|
88
|
+
absolutePath: string
|
|
89
|
+
): Promise<string | undefined> {
|
|
90
|
+
try {
|
|
91
|
+
return await Bun.file(absolutePath).text();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (
|
|
94
|
+
typeof error === "object" &&
|
|
95
|
+
error !== null &&
|
|
96
|
+
"code" in error &&
|
|
97
|
+
(error as { code: unknown }).code === "ENOENT"
|
|
98
|
+
) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DiffLines, type ToolDiffSide } from "./DiffLines";
|
|
3
|
+
|
|
4
|
+
const side = (
|
|
5
|
+
lines: readonly string[],
|
|
6
|
+
hasTrailingNewline = true
|
|
7
|
+
): ToolDiffSide => ({
|
|
8
|
+
lines,
|
|
9
|
+
hasTrailingNewline,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("DiffLines.buildToolDiff", () => {
|
|
13
|
+
test("returns undefined when content and EOF state are identical", () => {
|
|
14
|
+
const result = DiffLines.buildToolDiff(
|
|
15
|
+
"/tmp/x.ts",
|
|
16
|
+
side(["alpha", "beta", "gamma"]),
|
|
17
|
+
side(["alpha", "beta", "gamma"]),
|
|
18
|
+
2
|
|
19
|
+
);
|
|
20
|
+
expect(result).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("captures a single-line modification with surrounding context", () => {
|
|
24
|
+
const result = DiffLines.buildToolDiff(
|
|
25
|
+
"/tmp/x.ts",
|
|
26
|
+
side(["alpha", "beta", "gamma"]),
|
|
27
|
+
side(["alpha", "BETA", "gamma"]),
|
|
28
|
+
2
|
|
29
|
+
);
|
|
30
|
+
expect(result).toBeDefined();
|
|
31
|
+
const hunk = result?.hunks[0];
|
|
32
|
+
expect(hunk).toBeDefined();
|
|
33
|
+
expect(hunk?.lines.map((line) => line.kind)).toEqual([
|
|
34
|
+
"context",
|
|
35
|
+
"removed",
|
|
36
|
+
"added",
|
|
37
|
+
"context",
|
|
38
|
+
]);
|
|
39
|
+
expect(hunk?.lines.map((line) => line.text)).toEqual([
|
|
40
|
+
"alpha",
|
|
41
|
+
"beta",
|
|
42
|
+
"BETA",
|
|
43
|
+
"gamma",
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("represents an all-added diff for a brand-new file", () => {
|
|
48
|
+
const result = DiffLines.buildToolDiff(
|
|
49
|
+
"/tmp/new.ts",
|
|
50
|
+
side([], false),
|
|
51
|
+
side(["alpha", "beta"]),
|
|
52
|
+
2
|
|
53
|
+
);
|
|
54
|
+
const hunk = result?.hunks[0];
|
|
55
|
+
expect(hunk?.lines.map((line) => line.kind)).toEqual(["added", "added"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("collapses far-apart changes into separate hunks", () => {
|
|
59
|
+
const oldLines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
60
|
+
const newLines = oldLines.slice();
|
|
61
|
+
newLines[1] = "EARLY";
|
|
62
|
+
newLines[18] = "LATE";
|
|
63
|
+
|
|
64
|
+
const result = DiffLines.buildToolDiff(
|
|
65
|
+
"/tmp/x.ts",
|
|
66
|
+
side(oldLines),
|
|
67
|
+
side(newLines),
|
|
68
|
+
1
|
|
69
|
+
);
|
|
70
|
+
expect(result?.hunks.length).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("attaches intra-line emphasis to a paired removed/added line", () => {
|
|
74
|
+
const result = DiffLines.buildToolDiff(
|
|
75
|
+
"/tmp/x.ts",
|
|
76
|
+
side(["const x = 1;"]),
|
|
77
|
+
side(["const y = 2;"]),
|
|
78
|
+
1
|
|
79
|
+
);
|
|
80
|
+
const lines = result?.hunks[0]?.lines ?? [];
|
|
81
|
+
const removed = lines.find((line) => line.kind === "removed");
|
|
82
|
+
const added = lines.find((line) => line.kind === "added");
|
|
83
|
+
|
|
84
|
+
expect(removed?.emphasis?.length ?? 0).toBeGreaterThan(0);
|
|
85
|
+
expect(added?.emphasis?.length ?? 0).toBeGreaterThan(0);
|
|
86
|
+
|
|
87
|
+
for (const range of removed?.emphasis ?? []) {
|
|
88
|
+
const slice = removed?.text.slice(range.start, range.end) ?? "";
|
|
89
|
+
expect(slice.includes("x") || slice.includes("1")).toBe(true);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("skips emphasis when removed and added runs are unequal length", () => {
|
|
94
|
+
const result = DiffLines.buildToolDiff(
|
|
95
|
+
"/tmp/x.ts",
|
|
96
|
+
side(["alpha"]),
|
|
97
|
+
side(["beta", "gamma"]),
|
|
98
|
+
1
|
|
99
|
+
);
|
|
100
|
+
const lines = result?.hunks[0]?.lines ?? [];
|
|
101
|
+
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
expect(line.emphasis).toBeUndefined();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("does not emphasize leading whitespace", () => {
|
|
108
|
+
const result = DiffLines.buildToolDiff(
|
|
109
|
+
"/tmp/x.ts",
|
|
110
|
+
side([" foo();"]),
|
|
111
|
+
side([" foo();"]),
|
|
112
|
+
1
|
|
113
|
+
);
|
|
114
|
+
const lines = result?.hunks[0]?.lines ?? [];
|
|
115
|
+
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
for (const range of line.emphasis ?? []) {
|
|
118
|
+
const slice = line.text.slice(range.start, range.end);
|
|
119
|
+
expect(/^\s/.test(slice)).toBe(false);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("skips emphasis when lines share no content", () => {
|
|
125
|
+
const result = DiffLines.buildToolDiff(
|
|
126
|
+
"/tmp/x.ts",
|
|
127
|
+
side(["foo"]),
|
|
128
|
+
side(["bar"]),
|
|
129
|
+
1
|
|
130
|
+
);
|
|
131
|
+
const lines = result?.hunks[0]?.lines ?? [];
|
|
132
|
+
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
expect(line.emphasis).toBeUndefined();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("returns undefined when only EOF newline state differs (callers surface EOF themselves)", () => {
|
|
139
|
+
const result = DiffLines.buildToolDiff(
|
|
140
|
+
"/tmp/x.ts",
|
|
141
|
+
side(["alpha"], true),
|
|
142
|
+
side(["alpha"], false),
|
|
143
|
+
2
|
|
144
|
+
);
|
|
145
|
+
expect(result).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("does not emit a phantom empty added line when appending to a newline-terminated file", () => {
|
|
149
|
+
const result = DiffLines.buildToolDiff(
|
|
150
|
+
"/tmp/x.ts",
|
|
151
|
+
side(["foo", "bar"], true),
|
|
152
|
+
side(["foo", "bar", "baz"], true),
|
|
153
|
+
1
|
|
154
|
+
);
|
|
155
|
+
const lines = result?.hunks[0]?.lines ?? [];
|
|
156
|
+
const added = lines.filter((line) => line.kind === "added");
|
|
157
|
+
expect(added).toHaveLength(1);
|
|
158
|
+
expect(added[0]?.text).toBe("baz");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("throws on negative contextSize", () => {
|
|
162
|
+
expect(() =>
|
|
163
|
+
DiffLines.buildToolDiff("/tmp/x.ts", side(["a"]), side(["b"]), -1)
|
|
164
|
+
).toThrow();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("DiffLines.fromText", () => {
|
|
169
|
+
test("treats the empty string as zero lines, no trailing newline", () => {
|
|
170
|
+
expect(DiffLines.fromText("")).toEqual({
|
|
171
|
+
lines: [],
|
|
172
|
+
hasTrailingNewline: false,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("distinguishes 'a' from 'a\\n'", () => {
|
|
177
|
+
expect(DiffLines.fromText("a")).toEqual({
|
|
178
|
+
lines: ["a"],
|
|
179
|
+
hasTrailingNewline: false,
|
|
180
|
+
});
|
|
181
|
+
expect(DiffLines.fromText("a\n")).toEqual({
|
|
182
|
+
lines: ["a"],
|
|
183
|
+
hasTrailingNewline: true,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("preserves embedded blank lines without collapsing them", () => {
|
|
188
|
+
expect(DiffLines.fromText("a\n\nb\n")).toEqual({
|
|
189
|
+
lines: ["a", "", "b"],
|
|
190
|
+
hasTrailingNewline: true,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|