pi-hashline-edit-pro 0.2.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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Single normalization layer that maps the dialects a model may emit onto the
3
+ * canonical hashline edit request before validation runs.
4
+ *
5
+ * The only dialect we still absorb is the `file_path` → `path` alias and the
6
+ * JSON-stringified `edits` array. Pi's native legacy `oldText`/`newText`
7
+ * shape (whether top-level or as `op: "replace_text"`) is no longer
8
+ * supported: the hashline protocol requires hash-anchored edits, and the
9
+ * legacy text-matching path is what produces the
10
+ * `[E_NO_MATCH] replace_text found no exact unique match` failure mode the
11
+ * model hits on whitespace/Unicode drift. Any model that still emits the
12
+ * legacy shape is rejected with a clear error in `assertEditItem` /
13
+ * `assertEditRequest` so it learns the correct shape on the next turn.
14
+ *
15
+ * This runs as the tool's `prepareArguments` hook, which Pi executes before AJV
16
+ * schema validation and before `execute()`. The output is plain enumerable data
17
+ * (an `edits` array), so Pi's `structuredClone` of prepareArguments output keeps
18
+ * every field.
19
+ */
20
+
21
+ import { isRecord, hasOwn } from "./utils";
22
+
23
+ /**
24
+ * Parse `edits` when a model serializes it as a JSON string instead of an array
25
+ * (observed with some models, mirrors Pi's built-in edit handling).
26
+ */
27
+ function coerceEditsArray(edits: unknown): unknown {
28
+ if (typeof edits !== "string") {
29
+ return edits;
30
+ }
31
+ try {
32
+ const parsed: unknown = JSON.parse(edits);
33
+ return Array.isArray(parsed) ? parsed : edits;
34
+ } catch {
35
+ return edits;
36
+ }
37
+ }
38
+
39
+
40
+ /**
41
+ * Normalize a raw edit-tool request into the canonical hashline shape.
42
+ *
43
+ * Returns the input unchanged when it is not an object, so malformed payloads
44
+ * still reach validation and surface a precise error there.
45
+ */
46
+ export function normalizeEditRequest(input: unknown): unknown {
47
+ if (!isRecord(input)) {
48
+ return input;
49
+ }
50
+
51
+ const record: Record<string, unknown> = { ...input };
52
+
53
+ // file_path → path alias.
54
+ if (typeof record.path !== "string" && typeof record.file_path === "string") {
55
+ record.path = record.file_path;
56
+ delete record.file_path;
57
+ }
58
+
59
+ const hasEditsField = hasOwn(record, "edits");
60
+
61
+ // edits-as-JSON-string → array.
62
+ if (hasEditsField) {
63
+ record.edits = coerceEditsArray(record.edits);
64
+ }
65
+
66
+ return record;
67
+ }
68
+
@@ -0,0 +1,280 @@
1
+ /**
2
+ * TUI rendering helpers for the edit tool.
3
+ *
4
+ * Extracted from `src/edit.ts` to separate presentation (color themes, diff
5
+ * formatting, Markdown rendering) from tool execution logic.
6
+ */
7
+
8
+ import type { Theme } from "@earendil-works/pi-coding-agent";
9
+ import { normalizeEditRequest } from "./edit-normalize";
10
+ import type { EditRequestParams, HashlineEditToolDetails } from "./edit";
11
+ import { isRecord } from "./utils";
12
+
13
+ // ─── Theme type aliases ─────────────────────────────────────────────────
14
+
15
+ export type FgTheme = Pick<Theme, "fg">;
16
+ export type CallTheme = Pick<Theme, "fg" | "bold">;
17
+ export type RenderedMarkdownTheme = Pick<
18
+ Theme,
19
+ "fg" | "bold" | "italic" | "underline" | "strikethrough"
20
+ >;
21
+
22
+ // ─── Render state ───────────────────────────────────────────────────────
23
+
24
+ export type EditPreview = { diff: string } | { error: string };
25
+
26
+ export type EditRenderState = {
27
+ argsKey?: string;
28
+ preview?: EditPreview;
29
+ previewGeneration?: number;
30
+ };
31
+
32
+ // ─── Preview input extraction ───────────────────────────────────────────
33
+
34
+
35
+ export function getRenderablePreviewInput(
36
+ args: unknown,
37
+ ): EditRequestParams | null {
38
+ let normalized: unknown;
39
+ try {
40
+ normalized = normalizeEditRequest(args);
41
+ } catch {
42
+ return null;
43
+ }
44
+ if (!isRecord(normalized) || typeof normalized.path !== "string") {
45
+ return null;
46
+ }
47
+
48
+ const request: EditRequestParams = { path: normalized.path };
49
+ if (Array.isArray(normalized.edits)) {
50
+ request.edits = normalized.edits as any;
51
+ }
52
+
53
+ return request.edits !== undefined ? request : null;
54
+ }
55
+
56
+ // ─── Diff formatting ────────────────────────────────────────────────────
57
+
58
+ export function colorDiffLines(lines: string[], theme: FgTheme): string[] {
59
+ return lines.map((line) => {
60
+ if (line.startsWith("+") && !line.startsWith("+++")) {
61
+ return theme.fg("success", line);
62
+ }
63
+ if (line.startsWith("-") && !line.startsWith("---")) {
64
+ return theme.fg("error", line);
65
+ }
66
+ return theme.fg("dim", line);
67
+ });
68
+ }
69
+
70
+ export function formatPreviewDiff(
71
+ diff: string,
72
+ expanded: boolean,
73
+ theme: FgTheme,
74
+ ): string {
75
+ const lines = diff.split("\n");
76
+ const maxLines = expanded ? 40 : 16;
77
+ const shown = colorDiffLines(lines.slice(0, maxLines), theme);
78
+
79
+ if (lines.length > maxLines) {
80
+ shown.push(
81
+ theme.fg("muted", `... ${lines.length - maxLines} more diff lines`),
82
+ );
83
+ }
84
+ return shown.join("\n");
85
+ }
86
+
87
+ export function formatResultDiff(diff: string, theme: FgTheme): string {
88
+ return colorDiffLines(diff.split("\n"), theme).join("\n");
89
+ }
90
+
91
+ // ─── Edit call formatting ───────────────────────────────────────────────
92
+
93
+ export function formatEditCall(
94
+ args: EditRequestParams | undefined,
95
+ state: EditRenderState,
96
+ expanded: boolean,
97
+ theme: CallTheme,
98
+ ): string {
99
+ const path = args?.path;
100
+ const pathDisplay =
101
+ typeof path === "string" && path.length > 0
102
+ ? theme.fg("accent", path)
103
+ : theme.fg("toolOutput", "...");
104
+ let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
105
+
106
+ if (!state.preview) {
107
+ return text;
108
+ }
109
+
110
+ if ("error" in state.preview) {
111
+ text += `\n\n${theme.fg("error", state.preview.error)}`;
112
+ return text;
113
+ }
114
+
115
+ if (state.preview.diff) {
116
+ text += `\n\n${formatPreviewDiff(state.preview.diff, expanded, theme)}`;
117
+ }
118
+ return text;
119
+ }
120
+
121
+ // ─── Result text extraction ─────────────────────────────────────────────
122
+
123
+ export function getRenderedEditTextContent(result: {
124
+ content?: Array<{ type: string; text?: string }>;
125
+ }): string | undefined {
126
+ const textContent = result.content?.find(
127
+ (entry): entry is { type: "text"; text: string } =>
128
+ entry.type === "text" && typeof entry.text === "string",
129
+ );
130
+ return textContent?.text;
131
+ }
132
+
133
+ export function extractRenderedWarnings(
134
+ text: string | undefined,
135
+ ): string | undefined {
136
+ return text?.match(/(?:^|\n)Warnings:\n[\s\S]*$/)?.[0]?.trimStart();
137
+ }
138
+
139
+ // ─── Result classification ──────────────────────────────────────────────
140
+
141
+ export function isAppliedChangedResult(
142
+ details: HashlineEditToolDetails | undefined,
143
+ ): boolean {
144
+ const metrics = details?.metrics;
145
+ return (
146
+ metrics?.classification === "applied" &&
147
+ metrics.return_mode === "changed" &&
148
+ metrics.added_lines !== undefined &&
149
+ metrics.removed_lines !== undefined
150
+ );
151
+ }
152
+
153
+ export function buildAppliedChangedResultText(
154
+ text: string | undefined,
155
+ details: HashlineEditToolDetails | undefined,
156
+ preview: EditPreview | undefined,
157
+ theme: FgTheme,
158
+ ): string | undefined {
159
+ const previewDiff =
160
+ preview && !("error" in preview) ? preview.diff : undefined;
161
+ const sections: string[] = [];
162
+
163
+ if (details?.diff && details.diff !== previewDiff) {
164
+ sections.push(formatResultDiff(details.diff, theme));
165
+ }
166
+
167
+ const warnings = extractRenderedWarnings(text);
168
+ if (warnings) sections.push(warnings);
169
+
170
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
171
+ }
172
+
173
+ // ─── Markdown rendering ─────────────────────────────────────────────────
174
+
175
+ function trimEdgeEmptyLines(lines: string[]): string[] {
176
+ let start = 0;
177
+ let end = lines.length;
178
+
179
+ while (start < end && lines[start] === "") {
180
+ start++;
181
+ }
182
+ while (end > start && lines[end - 1] === "") {
183
+ end--;
184
+ }
185
+
186
+ return lines.slice(start, end);
187
+ }
188
+
189
+ function isRenderedEditSectionBoundary(line: string): boolean {
190
+ return (
191
+ line === "--- Anchors ---" ||
192
+ line === "Warnings:" ||
193
+ line === "Structure outline:" ||
194
+ /^--- Range \d+ ---$/.test(line)
195
+ );
196
+ }
197
+
198
+ export function formatRenderedEditResultMarkdown(text: string): string {
199
+ const lines = text.split("\n");
200
+ const sections: string[] = [];
201
+ let plainLines: string[] = [];
202
+
203
+ const flushPlainLines = () => {
204
+ const trimmed = trimEdgeEmptyLines(plainLines);
205
+ if (trimmed.length > 0) {
206
+ sections.push(trimmed.join("\n"));
207
+ }
208
+ plainLines = [];
209
+ };
210
+
211
+ let index = 0;
212
+ while (index < lines.length) {
213
+ const line = lines[index]!;
214
+
215
+ if (line.startsWith("--- Anchors ")) {
216
+ flushPlainLines();
217
+ const title = line.replace(/^---\s*/, "").replace(/\s*---$/, "");
218
+ index++;
219
+ const bodyLines: string[] = [];
220
+ while (
221
+ index < lines.length &&
222
+ !isRenderedEditSectionBoundary(lines[index]!)
223
+ ) {
224
+ bodyLines.push(lines[index]!);
225
+ index++;
226
+ }
227
+ sections.push(
228
+ [
229
+ `#### ${title}`,
230
+ "```text",
231
+ ...trimEdgeEmptyLines(bodyLines),
232
+ "```",
233
+ ].join("\n"),
234
+ );
235
+ continue;
236
+ }
237
+
238
+ plainLines.push(line);
239
+ index++;
240
+ }
241
+
242
+ flushPlainLines();
243
+
244
+ return sections.join("\n\n");
245
+ }
246
+
247
+ export function createRenderedEditMarkdownTheme(theme: RenderedMarkdownTheme) {
248
+ return {
249
+ heading: (text: string) => theme.fg("mdHeading", text),
250
+ link: (text: string) => theme.fg("mdLink", text),
251
+ linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
252
+ code: (text: string) => theme.fg("mdCode", text),
253
+ codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
254
+ codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
255
+ quote: (text: string) => theme.fg("mdQuote", text),
256
+ quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
257
+ hr: (text: string) => theme.fg("mdHr", text),
258
+ listBullet: (text: string) => theme.fg("mdListBullet", text),
259
+ bold: (text: string) => theme.bold(text),
260
+ italic: (text: string) => (theme.italic ? theme.italic(text) : text),
261
+ underline: (text: string) =>
262
+ theme.underline ? theme.underline(text) : text,
263
+ strikethrough: (text: string) =>
264
+ theme.strikethrough ? theme.strikethrough(text) : text,
265
+ highlightCode: (code: string, lang?: string) =>
266
+ code.split("\n").map((line) => {
267
+ if (lang === "diff") {
268
+ if (line.startsWith("+") && !line.startsWith("+++")) {
269
+ return theme.fg("toolDiffAdded", line);
270
+ }
271
+ if (line.startsWith("-") && !line.startsWith("---")) {
272
+ return theme.fg("toolDiffRemoved", line);
273
+ }
274
+ return theme.fg("toolDiffContext", line);
275
+ }
276
+
277
+ return theme.fg("mdCodeBlock", line);
278
+ }),
279
+ };
280
+ }