@hheei/pi-apply-patch 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +3 -0
- package/dist/apply-patch/executor.d.ts +15 -0
- package/dist/apply-patch/executor.js +407 -0
- package/dist/apply-patch/fs.d.ts +37 -0
- package/dist/apply-patch/fs.js +186 -0
- package/dist/apply-patch/glyph.d.ts +8 -0
- package/dist/apply-patch/glyph.js +23 -0
- package/dist/apply-patch/index.d.ts +6 -0
- package/dist/apply-patch/index.js +5 -0
- package/dist/apply-patch/jsdiff-matching.d.ts +13 -0
- package/dist/apply-patch/jsdiff-matching.js +613 -0
- package/dist/apply-patch/jsdiff-worker.d.ts +1 -0
- package/dist/apply-patch/jsdiff-worker.js +18 -0
- package/dist/apply-patch/jsdiff.d.ts +9 -0
- package/dist/apply-patch/jsdiff.js +219 -0
- package/dist/apply-patch/outcome.d.ts +84 -0
- package/dist/apply-patch/outcome.js +1 -0
- package/dist/apply-patch/parser.d.ts +85 -0
- package/dist/apply-patch/parser.js +486 -0
- package/dist/apply-patch/paths.d.ts +5 -0
- package/dist/apply-patch/paths.js +23 -0
- package/dist/apply-patch/policy.d.ts +11 -0
- package/dist/apply-patch/policy.js +44 -0
- package/dist/apply-patch/presentation.d.ts +7 -0
- package/dist/apply-patch/presentation.js +127 -0
- package/dist/apply-patch/settings.d.ts +5 -0
- package/dist/apply-patch/settings.js +33 -0
- package/dist/apply-patch/tool.d.ts +18 -0
- package/dist/apply-patch/tool.js +178 -0
- package/dist/apply-patch/view.d.ts +34 -0
- package/dist/apply-patch/view.js +256 -0
- package/dist/apply-patch-tool.d.ts +1 -0
- package/dist/apply-patch-tool.js +1 -0
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +22 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +57 -5
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
|
+
import { agentResultText, isRecord, } from "@hheei/pi-ext-core";
|
|
3
|
+
import { createV4aPreviewCursor, previewV4aPatchPrefix } from "./parser.js";
|
|
4
|
+
import { applyPatchResultFacts, applyPatchSummary, formatApplyPatchOperationRow, formatApplyPatchRejectionRows, formatApplyPatchSnapshotRows, } from "./view.js";
|
|
5
|
+
/**
|
|
6
|
+
* Preview cursors live in this module's own map, keyed by the row's shared render state: the cursor
|
|
7
|
+
* is display-only progress, so it is never persisted and never read back from the session.
|
|
8
|
+
*/
|
|
9
|
+
const previewCursors = new WeakMap();
|
|
10
|
+
function patchArgument(args) {
|
|
11
|
+
return isRecord(args) && typeof args.patch === "string" ? args.patch : "";
|
|
12
|
+
}
|
|
13
|
+
function pendingOperation(operation) {
|
|
14
|
+
return {
|
|
15
|
+
operationIndex: 0,
|
|
16
|
+
kind: operation.kind,
|
|
17
|
+
path: operation.path,
|
|
18
|
+
addedLines: operation.addedLines,
|
|
19
|
+
removedLines: operation.removedLines,
|
|
20
|
+
status: "pending",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Incremental preview of the operation headers recognized so far. The cursor belongs to the row, so
|
|
25
|
+
* one streamed update only reads the lines the previous one had not seen.
|
|
26
|
+
*/
|
|
27
|
+
function previewOperations(patch, context) {
|
|
28
|
+
const key = typeof context.state === "object" && context.state !== null ? context.state : undefined;
|
|
29
|
+
const cursor = key === undefined
|
|
30
|
+
? createV4aPreviewCursor()
|
|
31
|
+
: (previewCursors.get(key) ?? createV4aPreviewCursor());
|
|
32
|
+
const operations = previewV4aPatchPrefix(patch, context.argsComplete === true, cursor);
|
|
33
|
+
if (key !== undefined)
|
|
34
|
+
previewCursors.set(key, cursor);
|
|
35
|
+
return operations.map(pendingOperation);
|
|
36
|
+
}
|
|
37
|
+
function operationRows(operations, theme) {
|
|
38
|
+
return operations.map((operation) => formatApplyPatchOperationRow(operation, theme));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The call phase: operation headers recognized in the streamed patch as pending rows. The glyph only
|
|
42
|
+
* marks a recognized header — it promises neither validation nor application.
|
|
43
|
+
*/
|
|
44
|
+
function callPresentation(args, theme, context, isError) {
|
|
45
|
+
const patch = patchArgument(args);
|
|
46
|
+
const operations = previewOperations(patch, context);
|
|
47
|
+
const rows = operationRows(operations, theme);
|
|
48
|
+
const files = new Set(operations.map((operation) => operation.path)).size;
|
|
49
|
+
return {
|
|
50
|
+
summary: files > 0
|
|
51
|
+
? applyPatchSummary(files, operations.reduce((sum, operation) => sum + operation.addedLines, 0), operations.reduce((sum, operation) => sum + operation.removedLines, 0))
|
|
52
|
+
: "",
|
|
53
|
+
status: isError ? "error" : "none",
|
|
54
|
+
...(rows.length === 0 ? {} : { collapsedRows: rows }),
|
|
55
|
+
...(rows.length === 0 ? {} : { sections: [{ railToken: "dim", content: rows }] }),
|
|
56
|
+
...(patch === "" ? {} : { copy: { primary: patch } }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function resultStatus(facts, isError, isPartial) {
|
|
60
|
+
if (facts === undefined)
|
|
61
|
+
return isError ? "error" : isPartial ? "running" : "ok";
|
|
62
|
+
if (facts.status === "failed")
|
|
63
|
+
return "error";
|
|
64
|
+
if (facts.status === "partial")
|
|
65
|
+
return "warn";
|
|
66
|
+
return "none";
|
|
67
|
+
}
|
|
68
|
+
/** Result sections: operation rows, one row block per applied snapshot, then the failure rows. */
|
|
69
|
+
function resultSections(facts, theme) {
|
|
70
|
+
const sections = [];
|
|
71
|
+
const rows = operationRows(facts.operations, theme);
|
|
72
|
+
if (rows.length > 0)
|
|
73
|
+
sections.push({ railToken: "dim", content: rows });
|
|
74
|
+
for (const snapshot of facts.snapshots) {
|
|
75
|
+
const snapshotRows = formatApplyPatchSnapshotRows(snapshot, theme);
|
|
76
|
+
if (snapshotRows.length > 0)
|
|
77
|
+
sections.push({ railToken: "dim", content: snapshotRows });
|
|
78
|
+
}
|
|
79
|
+
const failures = [
|
|
80
|
+
...formatApplyPatchRejectionRows(facts.rejected, "rejected", theme),
|
|
81
|
+
...formatApplyPatchRejectionRows(facts.unconfirmed, "unconfirmed", theme),
|
|
82
|
+
...formatApplyPatchRejectionRows(facts.notApplied, "not_applied", theme),
|
|
83
|
+
];
|
|
84
|
+
if (failures.length > 0)
|
|
85
|
+
sections.push({ railToken: "error", content: failures });
|
|
86
|
+
return sections;
|
|
87
|
+
}
|
|
88
|
+
function copySpec(patch, output) {
|
|
89
|
+
if (patch === "")
|
|
90
|
+
return output === "" ? undefined : { primary: output };
|
|
91
|
+
return output === "" ? { primary: patch } : { primary: patch, full: `${patch}\n---\n${output}` };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The owned presentation of one apply_patch call or result. Facts come from the executor's
|
|
95
|
+
* persisted details, never from the current workspace; a result without those details keeps its
|
|
96
|
+
* model-visible text.
|
|
97
|
+
*/
|
|
98
|
+
export const applyPatchPresentation = (args, result, theme, context) => {
|
|
99
|
+
const isError = context.isError || result?.isError === true;
|
|
100
|
+
if (result === undefined)
|
|
101
|
+
return callPresentation(args, theme, context, isError);
|
|
102
|
+
const output = stripTerminalSequences(agentResultText(result));
|
|
103
|
+
const copy = copySpec(patchArgument(args), output);
|
|
104
|
+
const facts = applyPatchResultFacts(result.details);
|
|
105
|
+
if (facts === undefined) {
|
|
106
|
+
return {
|
|
107
|
+
summary: "",
|
|
108
|
+
status: resultStatus(facts, isError, context.isPartial),
|
|
109
|
+
replaceCallOnPartial: true,
|
|
110
|
+
...(output === "" ? {} : { output }),
|
|
111
|
+
...(output === ""
|
|
112
|
+
? {}
|
|
113
|
+
: { sections: [{ railToken: isError ? "error" : "dim", content: output.split("\n") }] }),
|
|
114
|
+
...(copy === undefined ? {} : { copy }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const rows = operationRows(facts.operations, theme);
|
|
118
|
+
return {
|
|
119
|
+
summary: applyPatchSummary(new Set(facts.changedPaths).size, facts.addedLines, facts.removedLines),
|
|
120
|
+
status: resultStatus(facts, isError, context.isPartial),
|
|
121
|
+
replaceCallOnPartial: true,
|
|
122
|
+
...(output === "" ? {} : { output }),
|
|
123
|
+
...(rows.length === 0 ? {} : { collapsedRows: rows }),
|
|
124
|
+
sections: resultSections(facts, theme),
|
|
125
|
+
...(copy === undefined ? {} : { copy }),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createJsonSettingsStorage } from "@hheei/pi-ext-core";
|
|
2
|
+
import { APPLY_PATCH_SETTINGS_KEY } from "./policy.js";
|
|
3
|
+
export function createApplyPatchSettingsProvider(options = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: "pi-apply-patch",
|
|
6
|
+
title: "Apply Patch",
|
|
7
|
+
origin: "@hheei/pi-apply-patch",
|
|
8
|
+
description: "Configure Codex V4A apply_patch editing tool parameters.",
|
|
9
|
+
groups: [
|
|
10
|
+
{
|
|
11
|
+
id: APPLY_PATCH_SETTINGS_KEY,
|
|
12
|
+
title: "",
|
|
13
|
+
fields: [
|
|
14
|
+
{
|
|
15
|
+
id: "fuzzFactor",
|
|
16
|
+
label: "Context Fuzz Factor",
|
|
17
|
+
type: "number",
|
|
18
|
+
defaultValue: 0,
|
|
19
|
+
description: "Line-edit context tolerance (0 for strict, 2 for relaxed context line matching).",
|
|
20
|
+
parse: (value) => Number.parseInt(value, 10),
|
|
21
|
+
validate: (value) => value === 0 || value === 2
|
|
22
|
+
? undefined
|
|
23
|
+
: "applyPatch.fuzzFactor must be an integer from 0 to 2",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
storage: createJsonSettingsStorage({
|
|
29
|
+
...(options.path === undefined ? {} : { path: options.path }),
|
|
30
|
+
group: APPLY_PATCH_SETTINGS_KEY,
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import { type ApplyPatchInWorkspaceResult, type ApplyPatchProgress } from "./index.js";
|
|
4
|
+
export declare const APPLY_PATCH_PARAMETERS: Type.TObject<{
|
|
5
|
+
patch: Type.TString;
|
|
6
|
+
}>;
|
|
7
|
+
type ApplyPatchParameters = Static<typeof APPLY_PATCH_PARAMETERS>;
|
|
8
|
+
export type ApplyPatchStatus = "success" | "partial" | "failed";
|
|
9
|
+
export interface ApplyPatchToolDetails extends ApplyPatchInWorkspaceResult {
|
|
10
|
+
readonly status: ApplyPatchStatus;
|
|
11
|
+
readonly progress?: ApplyPatchProgress;
|
|
12
|
+
}
|
|
13
|
+
export declare function isApplyPatchToolDetails(value: unknown): value is ApplyPatchToolDetails;
|
|
14
|
+
export declare function parseApplyPatchParameters(params: unknown): ApplyPatchParameters;
|
|
15
|
+
export declare function formatApplyPatchResult(result: ApplyPatchInWorkspaceResult): string;
|
|
16
|
+
export declare function createApplyPatchTool(): ToolDefinition<typeof APPLY_PATCH_PARAMETERS, ApplyPatchToolDetails, unknown>;
|
|
17
|
+
export declare function registerApplyPatchTool(pi: ExtensionAPI): ToolDefinition;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createOptionalToolRenderers, createToolPresentationRenderers, defaultExtensionSettingsPaths, errorMessage, textToolResult, } from "@hheei/pi-ext-core";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { applyPatchInWorkspace, loadApplyPatchPolicy, } from "./index.js";
|
|
4
|
+
import { isPatchPathOutsideWorkspace } from "./paths.js";
|
|
5
|
+
import { applyPatchPresentation } from "./presentation.js";
|
|
6
|
+
const MAX_CANDIDATES = 6;
|
|
7
|
+
const APPLY_PATCH_DESCRIPTION = "Apply a V4A patch to files (32 MiB per file).";
|
|
8
|
+
const APPLY_PATCH_PARAMETER_DESCRIPTION = "One unfenced *** Begin Patch / *** End Patch envelope with Add/Update/Delete File sections. Paths may be absolute or workspace-relative.";
|
|
9
|
+
const APPLY_PATCH_PROMPT_SNIPPET = "Apply related file changes in one V4A patch.";
|
|
10
|
+
const APPLY_PATCH_PROMPT_GUIDELINES = [
|
|
11
|
+
"apply_patch: use @@ text anchors or *** End of File; pure moves use *** Move to: without a hunk.",
|
|
12
|
+
"apply_patch: confirmed changes persist after failure or cancellation.",
|
|
13
|
+
];
|
|
14
|
+
export const APPLY_PATCH_PARAMETERS = Type.Object({
|
|
15
|
+
patch: Type.String({ description: APPLY_PATCH_PARAMETER_DESCRIPTION }),
|
|
16
|
+
}, { additionalProperties: false });
|
|
17
|
+
export function isApplyPatchToolDetails(value) {
|
|
18
|
+
return (typeof value === "object" &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
!Array.isArray(value) &&
|
|
21
|
+
"status" in value &&
|
|
22
|
+
(value.status === "success" ||
|
|
23
|
+
value.status === "partial" ||
|
|
24
|
+
value.status === "failed"));
|
|
25
|
+
}
|
|
26
|
+
export function parseApplyPatchParameters(params) {
|
|
27
|
+
if (typeof params !== "object" || params === null || Array.isArray(params))
|
|
28
|
+
throw new Error("apply_patch requires exactly one string parameter: patch");
|
|
29
|
+
const keys = Object.keys(params);
|
|
30
|
+
if (keys.length !== 1 || !("patch" in params) || typeof params.patch !== "string")
|
|
31
|
+
throw new Error("apply_patch requires exactly one string parameter: patch");
|
|
32
|
+
return { patch: params.patch };
|
|
33
|
+
}
|
|
34
|
+
function statusFor(result) {
|
|
35
|
+
if (!result.rejected.some((rejection) => rejection.operationIndices.length > 0) &&
|
|
36
|
+
result.unconfirmed.length === 0 &&
|
|
37
|
+
result.notApplied.length === 0)
|
|
38
|
+
return "success";
|
|
39
|
+
return result.changedPaths.length === 0 ? "failed" : "partial";
|
|
40
|
+
}
|
|
41
|
+
function operationText(index) {
|
|
42
|
+
return `operation ${index + 1}`;
|
|
43
|
+
}
|
|
44
|
+
function rejectionLines(rejection) {
|
|
45
|
+
const operation = rejection.operationIndices.map(operationText).join(", ");
|
|
46
|
+
const path = rejection.paths.join(", ");
|
|
47
|
+
const prefix = `- ${operation}, ${path}`;
|
|
48
|
+
if (rejection.diagnostics.length === 0)
|
|
49
|
+
return [`${prefix}: ${rejection.error}`];
|
|
50
|
+
return rejection.diagnostics.map((diagnostic) => {
|
|
51
|
+
switch (diagnostic.kind) {
|
|
52
|
+
case "context_not_found":
|
|
53
|
+
return diagnostic.hint !== undefined
|
|
54
|
+
? `${prefix}, hunk ${diagnostic.hunkIndex}: context not found (${diagnostic.hint})`
|
|
55
|
+
: `${prefix}, hunk ${diagnostic.hunkIndex}: context not found`;
|
|
56
|
+
case "ambiguous_exact":
|
|
57
|
+
return `${prefix}, hunk ${diagnostic.hunkIndex}: exact context is ambiguous at lines ${diagnostic.candidateStartLines
|
|
58
|
+
.slice(0, MAX_CANDIDATES)
|
|
59
|
+
.join(", ")} (${diagnostic.candidateStartLines.length} candidates)`;
|
|
60
|
+
default:
|
|
61
|
+
throw new Error(`Unknown patch diagnostic: ${String(diagnostic)}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function formatApplyPatchResult(result) {
|
|
66
|
+
const status = statusFor(result);
|
|
67
|
+
const rejectedHunksByOperation = new Map();
|
|
68
|
+
for (const rejection of result.rejected)
|
|
69
|
+
if (rejection.diagnostics.length > 0)
|
|
70
|
+
for (const operationIndex of rejection.operationIndices)
|
|
71
|
+
rejectedHunksByOperation.set(operationIndex, (rejectedHunksByOperation.get(operationIndex) ?? 0) + rejection.diagnostics.length);
|
|
72
|
+
const applied = result.applied.map((operation) => {
|
|
73
|
+
const path = operation.paths.at(-1) ?? "<unknown>";
|
|
74
|
+
const rejectedHunks = rejectedHunksByOperation.get(operation.operationIndex) ?? 0;
|
|
75
|
+
const totalHunks = operation.outcomes.length + rejectedHunks;
|
|
76
|
+
return `- ${path}: ${operation.kind}${totalHunks === 0 ? "" : ` (${operation.outcomes.length}/${totalHunks} hunks applied)`}`;
|
|
77
|
+
});
|
|
78
|
+
const fuzzy = result.applied.flatMap((operation) => operation.outcomes
|
|
79
|
+
.filter((outcome) => outcome.match === "fuzzy")
|
|
80
|
+
.map((outcome) => `- ${operation.paths.at(-1) ?? "unknown"}, ${operationText(operation.operationIndex)}, hunk ${outcome.hunkIndex}: lines ${outcome.startLine}-${outcome.startLine + outcome.length - 1}, fuzzy`));
|
|
81
|
+
const headline = status === "success"
|
|
82
|
+
? `Applied patch: ${result.applied.length} operations in ${result.changedPaths.length} files.`
|
|
83
|
+
: status === "partial"
|
|
84
|
+
? "Patch partially applied."
|
|
85
|
+
: "Patch was not applied.";
|
|
86
|
+
return [
|
|
87
|
+
headline,
|
|
88
|
+
...(applied.length === 0 ? [] : ["Changed:", ...applied]),
|
|
89
|
+
...(fuzzy.length === 0 ? [] : ["Fuzzy-applied:", ...fuzzy]),
|
|
90
|
+
...(result.rejected.length === 0
|
|
91
|
+
? []
|
|
92
|
+
: ["Rejected:", ...result.rejected.flatMap(rejectionLines)]),
|
|
93
|
+
...(result.unconfirmed.length === 0
|
|
94
|
+
? []
|
|
95
|
+
: ["Unconfirmed:", ...result.unconfirmed.flatMap(rejectionLines)]),
|
|
96
|
+
...(result.notApplied.length === 0
|
|
97
|
+
? []
|
|
98
|
+
: ["NotApplied:", ...result.notApplied.flatMap(rejectionLines)]),
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
101
|
+
function externalPathWarning(workspaceRoot, result) {
|
|
102
|
+
const paths = [
|
|
103
|
+
...new Set(result.changedPaths.filter((path) => isPatchPathOutsideWorkspace(workspaceRoot, path))),
|
|
104
|
+
];
|
|
105
|
+
return paths.length === 0
|
|
106
|
+
? ""
|
|
107
|
+
: `\nWarning: changed path outside the workspace: ${paths.join(", ")}`;
|
|
108
|
+
}
|
|
109
|
+
function progressDetails(progress) {
|
|
110
|
+
return {
|
|
111
|
+
changedPaths: [],
|
|
112
|
+
addedLines: progress.addedLines,
|
|
113
|
+
removedLines: progress.removedLines,
|
|
114
|
+
operations: progress.operations,
|
|
115
|
+
operationCount: progress.operations.length,
|
|
116
|
+
exactUpdateCount: 0,
|
|
117
|
+
fuzzyUpdateCount: 0,
|
|
118
|
+
applied: [],
|
|
119
|
+
rejected: [],
|
|
120
|
+
unconfirmed: [],
|
|
121
|
+
notApplied: [],
|
|
122
|
+
status: "success",
|
|
123
|
+
progress,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function createApplyPatchTool() {
|
|
127
|
+
return {
|
|
128
|
+
name: "apply_patch",
|
|
129
|
+
label: "apply_patch",
|
|
130
|
+
description: APPLY_PATCH_DESCRIPTION,
|
|
131
|
+
parameters: APPLY_PATCH_PARAMETERS,
|
|
132
|
+
promptSnippet: APPLY_PATCH_PROMPT_SNIPPET,
|
|
133
|
+
promptGuidelines: APPLY_PATCH_PROMPT_GUIDELINES,
|
|
134
|
+
executionMode: "sequential",
|
|
135
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
136
|
+
const { patch } = parseApplyPatchParameters(params);
|
|
137
|
+
try {
|
|
138
|
+
const policy = await loadApplyPatchPolicy({
|
|
139
|
+
paths: defaultExtensionSettingsPaths(ctx.cwd),
|
|
140
|
+
...(signal === undefined ? {} : { signal }),
|
|
141
|
+
});
|
|
142
|
+
const result = await applyPatchInWorkspace({
|
|
143
|
+
workspaceRoot: ctx.cwd,
|
|
144
|
+
patch,
|
|
145
|
+
policy,
|
|
146
|
+
...(signal === undefined ? {} : { signal }),
|
|
147
|
+
onProgress: (progress) => onUpdate?.({
|
|
148
|
+
content: [],
|
|
149
|
+
details: progressDetails(progress),
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
const warning = externalPathWarning(ctx.cwd, result);
|
|
153
|
+
const status = statusFor(result);
|
|
154
|
+
const isError = status !== "success";
|
|
155
|
+
return {
|
|
156
|
+
...textToolResult(`${formatApplyPatchResult(result)}${warning}`, {
|
|
157
|
+
...result,
|
|
158
|
+
status,
|
|
159
|
+
}),
|
|
160
|
+
...(isError ? { isError: true } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const message = errorMessage(error);
|
|
165
|
+
throw new Error(`apply_patch failed: ${message}`);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
export function registerApplyPatchTool(pi) {
|
|
171
|
+
const tool = {
|
|
172
|
+
...createApplyPatchTool(),
|
|
173
|
+
...createOptionalToolRenderers(pi, "apply_patch", createToolPresentationRenderers(pi, "apply_patch", applyPatchPresentation)),
|
|
174
|
+
defaultActive: false,
|
|
175
|
+
};
|
|
176
|
+
pi.registerTool(tool);
|
|
177
|
+
return tool;
|
|
178
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ApplyPatchHunkSnapshot, ApplyPatchOperationProgress, ApplyPatchOperationStatus, ApplyPatchRejection } from "./outcome.js";
|
|
3
|
+
/**
|
|
4
|
+
* Display-side reading of the persisted `apply_patch` details. The details are the contract; a
|
|
5
|
+
* session written by an older build reaches this module as untrusted JSON, so every field is
|
|
6
|
+
* narrowed once here and dropped when it does not match.
|
|
7
|
+
*/
|
|
8
|
+
export interface ApplyPatchResultFacts {
|
|
9
|
+
readonly status: "success" | "partial" | "failed";
|
|
10
|
+
readonly operations: readonly ApplyPatchOperationProgress[];
|
|
11
|
+
readonly snapshots: readonly ApplyPatchHunkSnapshot[];
|
|
12
|
+
readonly rejected: readonly ApplyPatchRejection[];
|
|
13
|
+
readonly unconfirmed: readonly ApplyPatchRejection[];
|
|
14
|
+
readonly notApplied: readonly ApplyPatchRejection[];
|
|
15
|
+
readonly addedLines: number;
|
|
16
|
+
readonly removedLines: number;
|
|
17
|
+
readonly changedPaths: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
/** Reads only the persisted `apply_patch` details; other shapes return undefined. */
|
|
20
|
+
export declare function applyPatchResultFacts(details: unknown): ApplyPatchResultFacts | undefined;
|
|
21
|
+
/** How one mutation outcome is drawn. One outcome never draws two symbols across the tools. */
|
|
22
|
+
export declare function applyPatchGlyph(status: string, theme: Theme): string;
|
|
23
|
+
/** The header summary every apply_patch row shares, including nested calls. */
|
|
24
|
+
export declare function applyPatchSummary(files: number, added: number, removed: number): string;
|
|
25
|
+
/** One operation row: glyph, verb, path, delta, fuzzy marker, hunk summary. */
|
|
26
|
+
export declare function formatApplyPatchOperationRow(operation: ApplyPatchOperationProgress, theme: Theme): string;
|
|
27
|
+
/** Rejected, unconfirmed and not-applied rows, each carrying its own outcome glyph. */
|
|
28
|
+
export declare function formatApplyPatchRejectionRows(rejections: readonly ApplyPatchRejection[], status: ApplyPatchOperationStatus, theme: Theme): readonly string[];
|
|
29
|
+
/**
|
|
30
|
+
* Rows of one applied snapshot: the single-column sign, absolute line number and text the shared
|
|
31
|
+
* diff body draws. Context rows come from the snapshot's own before/after lines, so no file context
|
|
32
|
+
* outside the recorded window is invented.
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatApplyPatchSnapshotRows(snapshot: ApplyPatchHunkSnapshot, theme: Theme): readonly string[];
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { isRecord } from "@hheei/pi-ext-core";
|
|
2
|
+
import { diffArrays } from "diff";
|
|
3
|
+
import { MUTATION_GLYPH, MUTATION_TONE } from "./glyph.js";
|
|
4
|
+
const OPERATION_STATUSES = new Set([
|
|
5
|
+
"pending",
|
|
6
|
+
"applied",
|
|
7
|
+
"partial",
|
|
8
|
+
"fuzzy",
|
|
9
|
+
"unconfirmed",
|
|
10
|
+
"not_applied",
|
|
11
|
+
"rejected",
|
|
12
|
+
]);
|
|
13
|
+
function isPatchStatus(value) {
|
|
14
|
+
return value === "success" || value === "partial" || value === "failed";
|
|
15
|
+
}
|
|
16
|
+
function numberField(source, key) {
|
|
17
|
+
const value = source[key];
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
function stringField(source, key) {
|
|
21
|
+
const value = source[key];
|
|
22
|
+
return typeof value === "string" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
function stringList(value) {
|
|
25
|
+
return Array.isArray(value)
|
|
26
|
+
? value.filter((entry) => typeof entry === "string")
|
|
27
|
+
: [];
|
|
28
|
+
}
|
|
29
|
+
function operationView(value) {
|
|
30
|
+
if (!isRecord(value))
|
|
31
|
+
return undefined;
|
|
32
|
+
const path = stringField(value, "path");
|
|
33
|
+
const kind = value.kind;
|
|
34
|
+
const status = value.status;
|
|
35
|
+
if (path === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (kind !== "add" && kind !== "delete" && kind !== "update")
|
|
38
|
+
return undefined;
|
|
39
|
+
if (typeof status !== "string" || !OPERATION_STATUSES.has(status))
|
|
40
|
+
return undefined;
|
|
41
|
+
const appliedHunks = numberField(value, "appliedHunks");
|
|
42
|
+
const totalHunks = numberField(value, "totalHunks");
|
|
43
|
+
const partialReason = stringField(value, "partialReason");
|
|
44
|
+
return {
|
|
45
|
+
operationIndex: numberField(value, "operationIndex") ?? 0,
|
|
46
|
+
kind,
|
|
47
|
+
path,
|
|
48
|
+
addedLines: numberField(value, "addedLines") ?? 0,
|
|
49
|
+
removedLines: numberField(value, "removedLines") ?? 0,
|
|
50
|
+
status: status,
|
|
51
|
+
...(appliedHunks === undefined ? {} : { appliedHunks }),
|
|
52
|
+
...(totalHunks === undefined ? {} : { totalHunks }),
|
|
53
|
+
...(partialReason === undefined ? {} : { partialReason }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function snapshotView(value) {
|
|
57
|
+
if (!isRecord(value))
|
|
58
|
+
return undefined;
|
|
59
|
+
const path = stringField(value, "path");
|
|
60
|
+
if (path === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
const before = value.before;
|
|
63
|
+
const after = value.after;
|
|
64
|
+
if (!Array.isArray(before) || !Array.isArray(after))
|
|
65
|
+
return undefined;
|
|
66
|
+
const startLine = numberField(value, "startLine");
|
|
67
|
+
return {
|
|
68
|
+
path,
|
|
69
|
+
hunkIndex: numberField(value, "hunkIndex") ?? 0,
|
|
70
|
+
startLine: startLine !== undefined && startLine > 0 ? startLine : 1,
|
|
71
|
+
afterStartLine: numberField(value, "afterStartLine") ?? startLine ?? 1,
|
|
72
|
+
before: before.filter((line) => typeof line === "string"),
|
|
73
|
+
after: after.filter((line) => typeof line === "string"),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function diagnosticView(value) {
|
|
77
|
+
if (!isRecord(value))
|
|
78
|
+
return undefined;
|
|
79
|
+
const hunkIndex = numberField(value, "hunkIndex");
|
|
80
|
+
if (hunkIndex === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
switch (value.kind) {
|
|
83
|
+
case "context_not_found": {
|
|
84
|
+
const hint = stringField(value, "hint");
|
|
85
|
+
return { kind: "context_not_found", hunkIndex, ...(hint === undefined ? {} : { hint }) };
|
|
86
|
+
}
|
|
87
|
+
case "ambiguous_exact":
|
|
88
|
+
return {
|
|
89
|
+
kind: "ambiguous_exact",
|
|
90
|
+
hunkIndex,
|
|
91
|
+
candidateStartLines: Array.isArray(value.candidateStartLines)
|
|
92
|
+
? value.candidateStartLines.filter((line) => typeof line === "number")
|
|
93
|
+
: [],
|
|
94
|
+
};
|
|
95
|
+
default:
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function rejectionView(value) {
|
|
100
|
+
if (!isRecord(value))
|
|
101
|
+
return undefined;
|
|
102
|
+
const error = stringField(value, "error");
|
|
103
|
+
if (error === undefined)
|
|
104
|
+
return undefined;
|
|
105
|
+
const operationIndices = Array.isArray(value.operationIndices)
|
|
106
|
+
? value.operationIndices.filter((index) => typeof index === "number")
|
|
107
|
+
: [];
|
|
108
|
+
return {
|
|
109
|
+
operationIndices,
|
|
110
|
+
paths: stringList(value.paths),
|
|
111
|
+
error,
|
|
112
|
+
diagnostics: Array.isArray(value.diagnostics)
|
|
113
|
+
? value.diagnostics
|
|
114
|
+
.map(diagnosticView)
|
|
115
|
+
.filter((diagnostic) => diagnostic !== undefined)
|
|
116
|
+
: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function rejectionList(value) {
|
|
120
|
+
if (!Array.isArray(value))
|
|
121
|
+
return [];
|
|
122
|
+
return value
|
|
123
|
+
.map(rejectionView)
|
|
124
|
+
.filter((rejection) => rejection !== undefined);
|
|
125
|
+
}
|
|
126
|
+
/** Applied-operation snapshots, in the executor's operation order. */
|
|
127
|
+
function snapshotList(value) {
|
|
128
|
+
if (!Array.isArray(value))
|
|
129
|
+
return [];
|
|
130
|
+
const snapshots = [];
|
|
131
|
+
for (const operation of value) {
|
|
132
|
+
if (!isRecord(operation) || !Array.isArray(operation.snapshots))
|
|
133
|
+
continue;
|
|
134
|
+
for (const snapshot of operation.snapshots) {
|
|
135
|
+
const view = snapshotView(snapshot);
|
|
136
|
+
if (view !== undefined)
|
|
137
|
+
snapshots.push(view);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return snapshots;
|
|
141
|
+
}
|
|
142
|
+
/** Reads only the persisted `apply_patch` details; other shapes return undefined. */
|
|
143
|
+
export function applyPatchResultFacts(details) {
|
|
144
|
+
if (!isRecord(details) || !isPatchStatus(details.status))
|
|
145
|
+
return undefined;
|
|
146
|
+
return {
|
|
147
|
+
status: details.status,
|
|
148
|
+
operations: Array.isArray(details.operations)
|
|
149
|
+
? details.operations
|
|
150
|
+
.map(operationView)
|
|
151
|
+
.filter((operation) => operation !== undefined)
|
|
152
|
+
: [],
|
|
153
|
+
snapshots: snapshotList(details.applied),
|
|
154
|
+
rejected: rejectionList(details.rejected),
|
|
155
|
+
unconfirmed: rejectionList(details.unconfirmed),
|
|
156
|
+
notApplied: rejectionList(details.notApplied),
|
|
157
|
+
addedLines: numberField(details, "addedLines") ?? 0,
|
|
158
|
+
removedLines: numberField(details, "removedLines") ?? 0,
|
|
159
|
+
changedPaths: stringList(details.changedPaths),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** How one mutation outcome is drawn. One outcome never draws two symbols across the tools. */
|
|
163
|
+
export function applyPatchGlyph(status, theme) {
|
|
164
|
+
const glyph = MUTATION_GLYPH[status] ?? "✗";
|
|
165
|
+
const tone = MUTATION_TONE[status] ?? "error";
|
|
166
|
+
return theme.fg(tone, glyph);
|
|
167
|
+
}
|
|
168
|
+
/** The header summary every apply_patch row shares, including nested calls. */
|
|
169
|
+
export function applyPatchSummary(files, added, removed) {
|
|
170
|
+
return `modify ${String(files)}${files === 1 ? " file" : " files"} (+${String(added)} -${String(removed)})`;
|
|
171
|
+
}
|
|
172
|
+
/** One operation row: glyph, verb, path, delta, fuzzy marker, hunk summary. */
|
|
173
|
+
export function formatApplyPatchOperationRow(operation, theme) {
|
|
174
|
+
const glyph = applyPatchGlyph(operation.status, theme);
|
|
175
|
+
const kind = operation.kind === "add" ? "create" : operation.kind === "delete" ? "delete" : "modify";
|
|
176
|
+
const path = operation.path;
|
|
177
|
+
const deltaParts = [];
|
|
178
|
+
if (operation.addedLines > 0)
|
|
179
|
+
deltaParts.push(theme.fg("success", `+${String(operation.addedLines)}`));
|
|
180
|
+
if (operation.removedLines > 0)
|
|
181
|
+
deltaParts.push(theme.fg("error", `-${String(operation.removedLines)}`));
|
|
182
|
+
const delta = deltaParts.length > 0 ? ` (${deltaParts.join(" ")})` : "";
|
|
183
|
+
const score = operation.status === "fuzzy" ? ` ${theme.fg("dim", "fuzzy")}` : "";
|
|
184
|
+
const hunks = operation.status === "partial" &&
|
|
185
|
+
operation.appliedHunks !== undefined &&
|
|
186
|
+
operation.totalHunks !== undefined
|
|
187
|
+
? ` ${theme.fg("dim", `(${String(operation.appliedHunks)}/${String(operation.totalHunks)} hunks applied${operation.partialReason === undefined ? "" : `; ${operation.partialReason}`})`)}`
|
|
188
|
+
: "";
|
|
189
|
+
return `${glyph} ${theme.fg("toolTitle", kind)} ${path}${delta}${score}${hunks}`.trimEnd();
|
|
190
|
+
}
|
|
191
|
+
function diagnosticText(diagnostic) {
|
|
192
|
+
switch (diagnostic.kind) {
|
|
193
|
+
case "context_not_found":
|
|
194
|
+
return diagnostic.hint === undefined
|
|
195
|
+
? "context not found"
|
|
196
|
+
: `context not found (${diagnostic.hint})`;
|
|
197
|
+
case "ambiguous_exact":
|
|
198
|
+
return `exact context is ambiguous at lines ${diagnostic.candidateStartLines.join(", ")}`;
|
|
199
|
+
default:
|
|
200
|
+
return "context not applied";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Rejected, unconfirmed and not-applied rows, each carrying its own outcome glyph. */
|
|
204
|
+
export function formatApplyPatchRejectionRows(rejections, status, theme) {
|
|
205
|
+
const rows = [];
|
|
206
|
+
for (const rejection of rejections) {
|
|
207
|
+
const prefix = `${applyPatchGlyph(status, theme)} ${rejection.paths[0] ?? "<unknown>"} · `;
|
|
208
|
+
if (rejection.diagnostics.length === 0) {
|
|
209
|
+
rows.push(`${prefix}${rejection.error}`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
for (const diagnostic of rejection.diagnostics) {
|
|
213
|
+
rows.push(`${prefix}${diagnosticText(diagnostic)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Rows of one applied snapshot: the single-column sign, absolute line number and text the shared
|
|
220
|
+
* diff body draws. Context rows come from the snapshot's own before/after lines, so no file context
|
|
221
|
+
* outside the recorded window is invented.
|
|
222
|
+
*/
|
|
223
|
+
export function formatApplyPatchSnapshotRows(snapshot, theme) {
|
|
224
|
+
const lines = snapshotLines(snapshot);
|
|
225
|
+
if (lines.length === 0)
|
|
226
|
+
return [];
|
|
227
|
+
const numberWidth = Math.max(2, ...lines.map((line) => String(line.line).length));
|
|
228
|
+
const rows = lines.map((line) => `${theme.fg(line.tone, `${line.sign}${String(line.line).padStart(numberWidth)} ${line.text}`)}`);
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
/** Line LCS of one stored before/after window, numbered from the snapshot's own start lines. */
|
|
232
|
+
function snapshotLines(snapshot) {
|
|
233
|
+
if (snapshot.before.length === 0 && snapshot.after.length === 0)
|
|
234
|
+
return [];
|
|
235
|
+
const rows = [];
|
|
236
|
+
let oldLine = snapshot.startLine;
|
|
237
|
+
let newLine = snapshot.afterStartLine;
|
|
238
|
+
for (const part of diffArrays([...snapshot.before], [...snapshot.after])) {
|
|
239
|
+
for (const text of part.value) {
|
|
240
|
+
if (part.added === true) {
|
|
241
|
+
rows.push({ sign: "+", tone: "success", line: newLine, text });
|
|
242
|
+
newLine += 1;
|
|
243
|
+
}
|
|
244
|
+
else if (part.removed === true) {
|
|
245
|
+
rows.push({ sign: "-", tone: "error", line: oldLine, text });
|
|
246
|
+
oldLine += 1;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
rows.push({ sign: " ", tone: "dim", line: newLine, text });
|
|
250
|
+
oldLine += 1;
|
|
251
|
+
newLine += 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./apply-patch/tool.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./apply-patch/tool.js";
|