@shinynito/pi-apply-patch 0.1.5
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 +9 -0
- package/package.json +52 -0
- package/src/index.ts +228 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShinyNito
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @shinynito/pi-apply-patch
|
|
2
|
+
|
|
3
|
+
Pi extension that adds a Codex-style `apply_patch` tool backed exclusively by a Rust N-API binary.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pi install npm:@shinynito/pi-apply-patch
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The matcher is exact and SIMD-accelerated. Whitespace and Unicode punctuation mismatches are errors; there is no JavaScript or tolerant-matching fallback.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shinynito/pi-apply-patch",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "SIMD-accelerated Codex-style apply_patch tool for Pi",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/ShinyNito/pi-apply-patch.git",
|
|
13
|
+
"directory": "packages/pi-apply-patch"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/ShinyNito/pi-apply-patch#readme",
|
|
16
|
+
"bugs": "https://github.com/ShinyNito/pi-apply-patch/issues",
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.19.0"
|
|
24
|
+
},
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"provenance": true
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@shinynito/apply-patch-node": "^0.1.5"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
44
|
+
"@earendil-works/pi-tui": "^0.84.3",
|
|
45
|
+
"typebox": "^1.0.0",
|
|
46
|
+
"typescript": "^5.9.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"test": "pnpm typecheck"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_MAX_BYTES,
|
|
4
|
+
DEFAULT_MAX_LINES,
|
|
5
|
+
formatSize,
|
|
6
|
+
keyHint,
|
|
7
|
+
truncateHead,
|
|
8
|
+
withFileMutationQueue,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AppliedChange,
|
|
15
|
+
ApplyPatchProgress,
|
|
16
|
+
ApplyPatchResult,
|
|
17
|
+
} from "@shinynito/apply-patch-node";
|
|
18
|
+
import { applyPatch, parsePatch } from "@shinynito/apply-patch-node";
|
|
19
|
+
|
|
20
|
+
const parameters = Type.Object({
|
|
21
|
+
patch: Type.String({
|
|
22
|
+
description:
|
|
23
|
+
"A complete file-oriented patch wrapped in *** Begin Patch and *** End Patch.",
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function formatChange(change: AppliedChange): string {
|
|
28
|
+
const path = change.moveTo === undefined ? change.path : `${change.path} -> ${change.moveTo}`;
|
|
29
|
+
const prefix = change.operation === "add" ? "A" : change.operation === "delete" ? "D" : "M";
|
|
30
|
+
return `${prefix} ${path}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type OperationCounts = { add: number; update: number; delete: number };
|
|
34
|
+
|
|
35
|
+
function countChanges(changes: AppliedChange[]): OperationCounts {
|
|
36
|
+
return changes.reduce<OperationCounts>(
|
|
37
|
+
(counts, change) => {
|
|
38
|
+
counts[change.operation] += 1;
|
|
39
|
+
return counts;
|
|
40
|
+
},
|
|
41
|
+
{ add: 0, update: 0, delete: 0 },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function countPatchOperations(patch: string): OperationCounts {
|
|
46
|
+
return parsePatch(patch).reduce<OperationCounts>(
|
|
47
|
+
(counts, operation) => {
|
|
48
|
+
counts[operation.kind] += 1;
|
|
49
|
+
return counts;
|
|
50
|
+
},
|
|
51
|
+
{ add: 0, update: 0, delete: 0 },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function operationBadge(operation: "add" | "update" | "delete", theme: Theme): string {
|
|
56
|
+
const label = operation === "add" ? "A" : operation === "update" ? "M" : "D";
|
|
57
|
+
const color = operation === "add" ? "success" : operation === "update" ? "accent" : "warning";
|
|
58
|
+
return theme.fg(color, theme.bold(label));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatCounts(counts: OperationCounts, theme: Theme): string {
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
if (counts.add > 0) parts.push(`${operationBadge("add", theme)} ${counts.add}`);
|
|
64
|
+
if (counts.update > 0) parts.push(`${operationBadge("update", theme)} ${counts.update}`);
|
|
65
|
+
if (counts.delete > 0) parts.push(`${operationBadge("delete", theme)} ${counts.delete}`);
|
|
66
|
+
return parts.join(theme.fg("dim", " · "));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatPlainCounts(counts: OperationCounts): string {
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
if (counts.add > 0) parts.push(`A ${counts.add}`);
|
|
72
|
+
if (counts.update > 0) parts.push(`M ${counts.update}`);
|
|
73
|
+
if (counts.delete > 0) parts.push(`D ${counts.delete}`);
|
|
74
|
+
return parts.join(", ");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatUiChange(change: AppliedChange, theme: Theme): string {
|
|
78
|
+
const path = change.moveTo === undefined ? change.path : `${change.path} → ${change.moveTo}`;
|
|
79
|
+
return `${operationBadge(change.operation, theme)} ${theme.fg("toolOutput", path)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatUiDiffLine(line: string, theme: Theme): string {
|
|
83
|
+
const color = line.startsWith("+")
|
|
84
|
+
? "toolDiffAdded"
|
|
85
|
+
: line.startsWith("-")
|
|
86
|
+
? "toolDiffRemoved"
|
|
87
|
+
: "toolDiffContext";
|
|
88
|
+
return theme.fg(color, line);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderText(text: string, lastComponent: unknown): Text {
|
|
92
|
+
const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
|
|
93
|
+
component.setText(text);
|
|
94
|
+
return component;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatPlainResult(changes: AppliedChange[]): string {
|
|
98
|
+
const plainText = `Applied patch to ${changes.length} ${changes.length === 1 ? "file" : "files"} (${formatPlainCounts(
|
|
99
|
+
countChanges(changes),
|
|
100
|
+
)}):\n${changes.flatMap((change) => [formatChange(change), ...(change.diff?.split("\n") ?? [])]).join("\n")}`;
|
|
101
|
+
const truncated = truncateHead(plainText, {
|
|
102
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
103
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
104
|
+
});
|
|
105
|
+
if (!truncated.truncated) return truncated.content;
|
|
106
|
+
return `${truncated.content}\n\n[Diff truncated: ${truncated.outputLines} of ${truncated.totalLines} lines, ${formatSize(
|
|
107
|
+
truncated.outputBytes,
|
|
108
|
+
)} of ${formatSize(truncated.totalBytes)}. Expand the tool row in the TUI for the full diff.]`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type ApplyPatchToolDetails = ApplyPatchResult | ApplyPatchProgress;
|
|
112
|
+
|
|
113
|
+
export default function applyPatchExtension(pi: ExtensionAPI): void {
|
|
114
|
+
pi.registerTool<typeof parameters, ApplyPatchToolDetails>({
|
|
115
|
+
name: "apply_patch",
|
|
116
|
+
label: "apply_patch",
|
|
117
|
+
description:
|
|
118
|
+
"Edit files with a complete Codex-style, file-oriented patch. The patch is fully validated before any write, then its file operations are applied in order.",
|
|
119
|
+
executionMode: "sequential",
|
|
120
|
+
parameters,
|
|
121
|
+
promptSnippet: "Use apply_patch for structured file edits with a complete Codex-style patch.",
|
|
122
|
+
promptGuidelines: [
|
|
123
|
+
"Provide one complete patch wrapped in *** Begin Patch and *** End Patch.",
|
|
124
|
+
"Each file operation must start with *** Add File:, *** Delete File:, or *** Update File:.",
|
|
125
|
+
"For renames, put *** Move to: immediately after the Update File header.",
|
|
126
|
+
"Start each update hunk with @@. Use about 3 lines of context before and after each change; use @@ <class or function> when context is not unique, and do not duplicate nearby context.",
|
|
127
|
+
"Prefix update lines with a space for context, - for removed lines, and + for added lines. Prefix every Add File line with +.",
|
|
128
|
+
"Use *** End of File when the final update hunk targets the end of a file.",
|
|
129
|
+
"Use relative file paths only. Pass the patch body directly in patch; do not wrap it in a shell command or heredoc.",
|
|
130
|
+
],
|
|
131
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
132
|
+
const result = await applyPatch(params.patch, ctx.cwd, {
|
|
133
|
+
signal,
|
|
134
|
+
includeDiff: true,
|
|
135
|
+
queue: (path, callback) => withFileMutationQueue(path, callback),
|
|
136
|
+
progress: (progress) => {
|
|
137
|
+
_onUpdate?.({
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: "text",
|
|
141
|
+
text:
|
|
142
|
+
progress.phase === "preparing"
|
|
143
|
+
? `Preparing patch (${progress.completed}/${progress.total} files)`
|
|
144
|
+
: `Applying patch (${progress.completed}/${progress.total} files)${progress.change ? `: ${formatChange(progress.change)}` : ""}`,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
details: progress,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
content: [
|
|
153
|
+
{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: formatPlainResult(result.changes),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
details: result,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
renderCall(args, theme, context) {
|
|
162
|
+
let text = theme.fg("toolTitle", theme.bold("apply_patch"));
|
|
163
|
+
if (!context.argsComplete) return renderText(text, context.lastComponent);
|
|
164
|
+
|
|
165
|
+
const counts = countPatchOperations(args.patch);
|
|
166
|
+
const total = counts.add + counts.update + counts.delete;
|
|
167
|
+
const fileLabel = total === 1 ? "file" : "files";
|
|
168
|
+
text += theme.fg("muted", ` · ${total} ${fileLabel}`);
|
|
169
|
+
const formattedCounts = formatCounts(counts, theme);
|
|
170
|
+
if (formattedCounts) text += theme.fg("dim", " ") + formattedCounts;
|
|
171
|
+
return renderText(text, context.lastComponent);
|
|
172
|
+
},
|
|
173
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
174
|
+
if (isPartial) {
|
|
175
|
+
const progress = result.details as ApplyPatchProgress | undefined;
|
|
176
|
+
if (progress?.phase === "preparing") {
|
|
177
|
+
return renderText(
|
|
178
|
+
`${theme.fg("warning", "…")} ${theme.fg("muted", `preparing ${progress.completed}/${progress.total}`)}`,
|
|
179
|
+
context.lastComponent,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (progress?.phase === "applying") {
|
|
183
|
+
const change = progress.change ? ` ${formatUiChange(progress.change, theme)}` : "";
|
|
184
|
+
return renderText(
|
|
185
|
+
`${theme.fg("warning", "…")} ${theme.fg("muted", `applying ${progress.completed}/${progress.total}`)}${change}`,
|
|
186
|
+
context.lastComponent,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return renderText(`${theme.fg("warning", "…")} ${theme.fg("muted", "applying patch")}`, context.lastComponent);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (context.isError) {
|
|
193
|
+
const message = result.content.find((item) => item.type === "text")?.text ?? "Patch failed";
|
|
194
|
+
return renderText(`${theme.fg("error", "×")} ${theme.fg("error", message)}`, context.lastComponent);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const details = result.details as ApplyPatchResult;
|
|
198
|
+
const counts = countChanges(details.changes);
|
|
199
|
+
const fileLabel = details.changes.length === 1 ? "file" : "files";
|
|
200
|
+
let text = `${theme.fg("success", "✓")} ${theme.fg("success", "applied")} ${theme.fg("muted", `· ${details.changes.length} ${fileLabel}`)}`;
|
|
201
|
+
const formattedCounts = formatCounts(counts, theme);
|
|
202
|
+
if (formattedCounts) text += ` ${formattedCounts}`;
|
|
203
|
+
|
|
204
|
+
const visibleChanges = expanded ? details.changes : details.changes.slice(0, 3);
|
|
205
|
+
const maxPreviewDiffLines = expanded ? Number.MAX_SAFE_INTEGER : 10;
|
|
206
|
+
let previewDiffLines = 0;
|
|
207
|
+
for (const change of visibleChanges) {
|
|
208
|
+
text += `\n ${formatUiChange(change, theme)}`;
|
|
209
|
+
|
|
210
|
+
const diff = change.diff?.split("\n") ?? [];
|
|
211
|
+
const remainingPreviewLines = maxPreviewDiffLines - previewDiffLines;
|
|
212
|
+
const visibleDiff = expanded ? diff : diff.slice(0, Math.max(0, remainingPreviewLines));
|
|
213
|
+
for (const line of visibleDiff) {
|
|
214
|
+
text += `\n ${formatUiDiffLine(line, theme)}`;
|
|
215
|
+
}
|
|
216
|
+
previewDiffLines += visibleDiff.length;
|
|
217
|
+
if (!expanded && visibleDiff.length < diff.length) {
|
|
218
|
+
text += `\n ${theme.fg("dim", `… ${diff.length - visibleDiff.length} more diff lines · `)}${keyHint("app.tools.expand", "expand")}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const remaining = details.changes.length - visibleChanges.length;
|
|
222
|
+
if (remaining > 0) {
|
|
223
|
+
text += `\n ${theme.fg("dim", `… ${remaining} more · `)}${keyHint("app.tools.expand", expanded ? "collapse" : "expand")}`;
|
|
224
|
+
}
|
|
225
|
+
return renderText(text, context.lastComponent);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|