@oh-my-pi/pi-coding-agent 14.2.1 → 14.3.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/CHANGELOG.md +59 -0
- package/package.json +19 -19
- package/src/cli/args.ts +10 -1
- package/src/cli/shell-cli.ts +15 -3
- package/src/config/settings-schema.ts +60 -1
- package/src/debug/system-info.ts +6 -2
- package/src/discovery/claude.ts +58 -36
- package/src/discovery/opencode.ts +20 -2
- package/src/edit/index.ts +2 -1
- package/src/edit/modes/chunk.ts +132 -56
- package/src/edit/modes/hashline.ts +36 -11
- package/src/edit/renderer.ts +98 -133
- package/src/edit/streaming.ts +351 -0
- package/src/exec/bash-executor.ts +60 -5
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/pi-protocol.ts +0 -2
- package/src/lsp/client.ts +8 -1
- package/src/lsp/defaults.json +2 -1
- package/src/modes/acp/acp-agent.ts +76 -2
- package/src/modes/components/assistant-message.ts +1 -34
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/tool-execution.ts +111 -101
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +0 -2
- package/src/modes/theme/mermaid-cache.ts +13 -52
- package/src/modes/theme/theme.ts +2 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/chunk-edit.md +25 -22
- package/src/prompts/tools/gh-pr-push.md +2 -1
- package/src/prompts/tools/grep.md +4 -3
- package/src/prompts/tools/lsp.md +6 -0
- package/src/prompts/tools/read-chunk.md +46 -7
- package/src/prompts/tools/read.md +7 -4
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +36 -20
- package/src/session/session-manager.ts +228 -57
- package/src/session/streaming-output.ts +11 -0
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +1 -0
- package/src/tools/bash.ts +13 -0
- package/src/tools/gh.ts +6 -16
- package/src/tools/sqlite-reader.ts +116 -3
- package/src/web/search/providers/codex.ts +129 -6
package/src/edit/modes/chunk.ts
CHANGED
|
@@ -32,7 +32,6 @@ export type { ChunkReadTarget };
|
|
|
32
32
|
|
|
33
33
|
export type ChunkEditOperation =
|
|
34
34
|
| { op: "put"; sel?: string; content: string }
|
|
35
|
-
| { op: "replace"; sel?: string; content: string; find: string }
|
|
36
35
|
| { op: "delete"; sel?: string }
|
|
37
36
|
| { op: "before"; sel?: string; content: string }
|
|
38
37
|
| { op: "after"; sel?: string; content: string }
|
|
@@ -120,6 +119,8 @@ type ChunkSourceContext = {
|
|
|
120
119
|
chunkLanguage: string | undefined;
|
|
121
120
|
};
|
|
122
121
|
|
|
122
|
+
type ChunkSourceIntent = "read" | "write";
|
|
123
|
+
|
|
123
124
|
function normalizeLanguage(language: string | undefined): string {
|
|
124
125
|
return language?.trim().toLowerCase() || "";
|
|
125
126
|
}
|
|
@@ -140,11 +141,17 @@ function fileLanguageTag(filePath: string, language?: string): string | undefine
|
|
|
140
141
|
return ext.length > 0 ? ext : undefined;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
async function resolveChunkSourceContext(
|
|
144
|
+
async function resolveChunkSourceContext(
|
|
145
|
+
session: ToolSession,
|
|
146
|
+
path: string,
|
|
147
|
+
options?: { intent?: ChunkSourceIntent },
|
|
148
|
+
): Promise<ChunkSourceContext> {
|
|
144
149
|
const resolvedPath = resolvePlanPath(session, path);
|
|
145
150
|
const sourceFile = Bun.file(resolvedPath);
|
|
146
151
|
const sourceExists = await sourceFile.exists();
|
|
147
|
-
|
|
152
|
+
if ((options?.intent ?? "write") === "write") {
|
|
153
|
+
enforcePlanModeWrite(session, path, { op: sourceExists ? "update" : "create" });
|
|
154
|
+
}
|
|
148
155
|
|
|
149
156
|
let rawContent = "";
|
|
150
157
|
if (sourceExists) {
|
|
@@ -161,6 +168,57 @@ async function resolveChunkSourceContext(session: ToolSession, path: string): Pr
|
|
|
161
168
|
};
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Preview-safe loader: read raw source without plan-mode enforcement or
|
|
173
|
+
* editable-file guards. Used by streaming diff previews that must not throw
|
|
174
|
+
* side-effecting errors while args are still being streamed.
|
|
175
|
+
*/
|
|
176
|
+
export async function loadChunkSource(params: {
|
|
177
|
+
cwd: string;
|
|
178
|
+
path: string;
|
|
179
|
+
}): Promise<{ resolvedPath: string; rawContent: string; language: string | undefined; exists: boolean }> {
|
|
180
|
+
const resolvedPath = nodePath.isAbsolute(params.path) ? params.path : nodePath.resolve(params.cwd, params.path);
|
|
181
|
+
const sourceFile = Bun.file(resolvedPath);
|
|
182
|
+
const exists = await sourceFile.exists();
|
|
183
|
+
const rawContent = exists ? await sourceFile.text() : "";
|
|
184
|
+
return { resolvedPath, rawContent, language: getLanguageFromPath(resolvedPath), exists };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Compute a unified diff preview for a chunk edit without applying it.
|
|
189
|
+
* Used for streaming previews while args are still arriving. Returns
|
|
190
|
+
* `{ error }` on any failure so callers can decide whether to surface it.
|
|
191
|
+
*/
|
|
192
|
+
export async function computeChunkDiff(
|
|
193
|
+
input: { path: string; edits: ChunkToolEdit[] },
|
|
194
|
+
cwd: string,
|
|
195
|
+
options?: { anchorStyle?: ChunkAnchorStyle; signal?: AbortSignal },
|
|
196
|
+
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
197
|
+
try {
|
|
198
|
+
options?.signal?.throwIfAborted?.();
|
|
199
|
+
const { filePath } = parseChunkEditPath(input.path);
|
|
200
|
+
if (!filePath) return { error: "chunk edit path is empty" };
|
|
201
|
+
const { resolvedPath, rawContent, language } = await loadChunkSource({ cwd, path: filePath });
|
|
202
|
+
options?.signal?.throwIfAborted?.();
|
|
203
|
+
const { operations } = normalizeChunkEditOperations(input.edits);
|
|
204
|
+
const result = applyChunkEdits({
|
|
205
|
+
source: rawContent,
|
|
206
|
+
language,
|
|
207
|
+
cwd,
|
|
208
|
+
filePath: resolvedPath,
|
|
209
|
+
operations,
|
|
210
|
+
anchorStyle: options?.anchorStyle,
|
|
211
|
+
});
|
|
212
|
+
options?.signal?.throwIfAborted?.();
|
|
213
|
+
if (!result.changed) {
|
|
214
|
+
return { diff: "", firstChangedLine: undefined };
|
|
215
|
+
}
|
|
216
|
+
return generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
164
222
|
function normalizeChunkRegionSyntax(text: string): string {
|
|
165
223
|
return text.replaceAll("@body", "~").replaceAll("@head", "^");
|
|
166
224
|
}
|
|
@@ -189,6 +247,20 @@ function chunkReadPathSeparatorIndex(readPath: string): number {
|
|
|
189
247
|
if (/^[a-zA-Z]:[/\\]/.test(readPath)) {
|
|
190
248
|
return readPath.indexOf(":", 2);
|
|
191
249
|
}
|
|
250
|
+
const urlMatch = readPath.match(/^([a-z][a-z0-9+.-]*):\/\//i);
|
|
251
|
+
if (urlMatch) {
|
|
252
|
+
const scheme = urlMatch[1].toLowerCase();
|
|
253
|
+
const urlPrefixEnd = urlMatch[0].length;
|
|
254
|
+
if (scheme === "local") {
|
|
255
|
+
const index = readPath.lastIndexOf(":");
|
|
256
|
+
return index >= urlPrefixEnd ? index : -1;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const pathStart = readPath.indexOf("/", urlPrefixEnd);
|
|
260
|
+
if (pathStart === -1) return -1;
|
|
261
|
+
const index = readPath.lastIndexOf(":");
|
|
262
|
+
return index >= pathStart ? index : -1;
|
|
263
|
+
}
|
|
192
264
|
return readPath.indexOf(":");
|
|
193
265
|
}
|
|
194
266
|
|
|
@@ -376,15 +448,6 @@ function toNativeEditOperation(
|
|
|
376
448
|
region: nativeRegion,
|
|
377
449
|
content: operation.content,
|
|
378
450
|
};
|
|
379
|
-
case "replace":
|
|
380
|
-
return {
|
|
381
|
-
op: ChunkEditOp.Replace,
|
|
382
|
-
sel: selector,
|
|
383
|
-
crc,
|
|
384
|
-
region: nativeRegion,
|
|
385
|
-
find: operation.find,
|
|
386
|
-
content: operation.content,
|
|
387
|
-
};
|
|
388
451
|
case "before":
|
|
389
452
|
return { op: ChunkEditOp.Before, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
390
453
|
case "after":
|
|
@@ -491,17 +554,14 @@ export const chunkToolEditSchema = Type.Object(
|
|
|
491
554
|
}),
|
|
492
555
|
write: Type.Optional(
|
|
493
556
|
Type.Union([Type.String(), Type.Null()], {
|
|
494
|
-
description:
|
|
557
|
+
description:
|
|
558
|
+
"Write complete new content to the targeted region. Null is rejected; use delete: true for deletion.",
|
|
495
559
|
}),
|
|
496
560
|
),
|
|
497
|
-
|
|
498
|
-
Type.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
new: Type.String({ description: "Replacement text." }),
|
|
502
|
-
},
|
|
503
|
-
{ description: "Find and replace a substring within the chunk." },
|
|
504
|
-
),
|
|
561
|
+
delete: Type.Optional(
|
|
562
|
+
Type.Boolean({
|
|
563
|
+
description: "Explicitly delete the targeted chunk. Must be true; include the current chunk ID.",
|
|
564
|
+
}),
|
|
505
565
|
),
|
|
506
566
|
insert: Type.Optional(
|
|
507
567
|
Type.Object(
|
|
@@ -549,11 +609,8 @@ export function isChunkParams(params: unknown): params is ChunkParams {
|
|
|
549
609
|
return false;
|
|
550
610
|
}
|
|
551
611
|
const first = params.edits[0];
|
|
552
|
-
// Accept a bare `{ path }` entry
|
|
553
|
-
//
|
|
554
|
-
// documented `{ path, write: null }` delete can arrive here as just
|
|
555
|
-
// `{ path }`. Rejecting that surfaced as a misleading
|
|
556
|
-
// "Invalid edit parameters for chunk mode." error.
|
|
612
|
+
// Accept a bare `{ path }` entry so the executor can return a targeted
|
|
613
|
+
// "missing operation" error instead of the generic schema failure.
|
|
557
614
|
return typeof first === "object" && first !== null && "path" in first;
|
|
558
615
|
}
|
|
559
616
|
|
|
@@ -605,6 +662,29 @@ function autoCorrectBodyIndent(content: string, index: number): { content: strin
|
|
|
605
662
|
return { content, warnings };
|
|
606
663
|
}
|
|
607
664
|
|
|
665
|
+
function chunkEditOperationFields(edit: ChunkToolEdit): string[] {
|
|
666
|
+
const fields: string[] = [];
|
|
667
|
+
if (edit.write !== undefined) fields.push("write");
|
|
668
|
+
if (edit.insert != null) fields.push("insert");
|
|
669
|
+
if (edit.delete === true) fields.push("delete");
|
|
670
|
+
return fields;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function assertSingleChunkOperation(edit: ChunkToolEdit, index: number): string {
|
|
674
|
+
const fields = chunkEditOperationFields(edit);
|
|
675
|
+
if (fields.length === 0) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
`Edit ${index + 1}: no operation specified. Use write:"..." to replace, insert:{loc,body} to insert, or delete:true to delete. Use the open tool to inspect chunks.`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
if (fields.length > 1) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`Edit ${index + 1}: multiple operation fields set (${fields.join(", ")}). Each chunk edit entry must have exactly one operation.`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
return fields[0];
|
|
686
|
+
}
|
|
687
|
+
|
|
608
688
|
function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
|
|
609
689
|
operations: ChunkEditOperation[];
|
|
610
690
|
warnings: string[];
|
|
@@ -612,26 +692,22 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
|
|
|
612
692
|
const warnings: string[] = [];
|
|
613
693
|
const operations = edits.map((edit, index): ChunkEditOperation => {
|
|
614
694
|
const { selector } = parseChunkEditPath(edit.path);
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
);
|
|
632
|
-
}
|
|
633
|
-
if (hasWrite) {
|
|
634
|
-
let writeContent = edit.write!;
|
|
695
|
+
const operation = assertSingleChunkOperation(edit, index);
|
|
696
|
+
if (operation === "write") {
|
|
697
|
+
if (edit.write === null) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Edit ${index + 1}: write:null no longer deletes chunks. Use delete:true to delete, or open the chunk to inspect its content without modifying the file.`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (typeof edit.write !== "string") {
|
|
703
|
+
throw new Error(`Edit ${index + 1}: write must be a string.`);
|
|
704
|
+
}
|
|
705
|
+
if (edit.write.length === 0) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`Edit ${index + 1}: write:"" is a destructive empty replacement. Use delete:true to delete the chunk, or open the chunk to inspect its content without modifying the file.`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
let writeContent = edit.write;
|
|
635
711
|
if (selector?.endsWith("~")) {
|
|
636
712
|
const corrected = autoCorrectBodyIndent(writeContent, index);
|
|
637
713
|
writeContent = corrected.content;
|
|
@@ -639,15 +715,12 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
|
|
|
639
715
|
}
|
|
640
716
|
return { op: "put", sel: selector, content: writeContent };
|
|
641
717
|
}
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if (hasInsert) {
|
|
649
|
-
const op = edit.insert!.loc === "prepend" ? "before" : "after";
|
|
650
|
-
let insertContent = edit.insert!.body;
|
|
718
|
+
if (operation === "insert") {
|
|
719
|
+
if (edit.insert == null || typeof edit.insert.body !== "string" || edit.insert.body.length === 0) {
|
|
720
|
+
throw new Error(`Edit ${index + 1}: insert.body must be a non-empty string.`);
|
|
721
|
+
}
|
|
722
|
+
const op = edit.insert.loc === "prepend" ? "before" : "after";
|
|
723
|
+
let insertContent = edit.insert.body;
|
|
651
724
|
if (selector?.endsWith("~")) {
|
|
652
725
|
const corrected = autoCorrectBodyIndent(insertContent, index);
|
|
653
726
|
insertContent = corrected.content;
|
|
@@ -655,7 +728,9 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
|
|
|
655
728
|
}
|
|
656
729
|
return { op, sel: selector, content: insertContent };
|
|
657
730
|
}
|
|
658
|
-
|
|
731
|
+
if (operation !== "delete") {
|
|
732
|
+
throw new Error(`Edit ${index + 1}: unsupported chunk edit operation "${operation}".`);
|
|
733
|
+
}
|
|
659
734
|
return { op: "delete", sel: selector };
|
|
660
735
|
});
|
|
661
736
|
return { operations, warnings };
|
|
@@ -717,6 +792,7 @@ export async function executeChunkSingle(
|
|
|
717
792
|
const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
|
|
718
793
|
session,
|
|
719
794
|
path,
|
|
795
|
+
{ intent: "write" },
|
|
720
796
|
);
|
|
721
797
|
const parentDir = nodePath.dirname(resolvedPath);
|
|
722
798
|
if (parentDir && parentDir !== ".") {
|
|
@@ -52,12 +52,14 @@ export type HashlineEdit =
|
|
|
52
52
|
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
|
|
53
53
|
const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
|
|
54
54
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
55
|
+
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
|
|
55
56
|
|
|
56
57
|
type LinePrefixStats = {
|
|
57
58
|
nonEmpty: number;
|
|
58
59
|
hashPrefixCount: number;
|
|
59
60
|
diffPlusHashPrefixCount: number;
|
|
60
61
|
diffPlusCount: number;
|
|
62
|
+
truncationNoticeCount: number;
|
|
61
63
|
};
|
|
62
64
|
|
|
63
65
|
function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
@@ -66,10 +68,15 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
|
66
68
|
hashPrefixCount: 0,
|
|
67
69
|
diffPlusHashPrefixCount: 0,
|
|
68
70
|
diffPlusCount: 0,
|
|
71
|
+
truncationNoticeCount: 0,
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
for (const line of lines) {
|
|
72
75
|
if (line.length === 0) continue;
|
|
76
|
+
if (READ_TRUNCATION_NOTICE_RE.test(line)) {
|
|
77
|
+
stats.truncationNoticeCount++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
73
80
|
stats.nonEmpty++;
|
|
74
81
|
if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++;
|
|
75
82
|
if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
|
|
@@ -79,6 +86,20 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
|
79
86
|
return stats;
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
function stripLeadingHashlinePrefixes(line: string): string {
|
|
90
|
+
let result = line;
|
|
91
|
+
let prev: string;
|
|
92
|
+
do {
|
|
93
|
+
prev = result;
|
|
94
|
+
result = result.replace(HASHLINE_PREFIX_RE, "");
|
|
95
|
+
} while (result !== prev);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _filterTruncationNotices(lines: string[]): string[] {
|
|
100
|
+
return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line));
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
export function stripNewLinePrefixes(lines: string[]): string[] {
|
|
83
104
|
const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines);
|
|
84
105
|
if (nonEmpty === 0) return lines;
|
|
@@ -88,20 +109,24 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
|
|
|
88
109
|
!stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
|
|
89
110
|
if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
|
|
90
111
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return line.replace(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
const mapped = lines
|
|
113
|
+
.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
|
|
114
|
+
.map(line => {
|
|
115
|
+
if (stripHash) return stripLeadingHashlinePrefixes(line);
|
|
116
|
+
if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
|
|
117
|
+
if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
|
|
118
|
+
return line.replace(HASHLINE_PREFIX_RE, "");
|
|
119
|
+
}
|
|
120
|
+
return line;
|
|
121
|
+
});
|
|
122
|
+
return mapped;
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
export function stripHashlinePrefixes(lines: string[]): string[] {
|
|
102
126
|
const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines);
|
|
103
|
-
if (nonEmpty === 0
|
|
104
|
-
|
|
127
|
+
if (nonEmpty === 0) return lines;
|
|
128
|
+
if (hashPrefixCount !== nonEmpty) return lines;
|
|
129
|
+
return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
|
|
105
130
|
}
|
|
106
131
|
|
|
107
132
|
const linesSchema = Type.Union([
|
|
@@ -530,7 +555,7 @@ export class HashlineMismatchError extends Error {
|
|
|
530
555
|
const lines: string[] = [];
|
|
531
556
|
|
|
532
557
|
lines.push(
|
|
533
|
-
|
|
558
|
+
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied. Use the updated LINE#ID references shown below (>>> marks changed lines) and retry the edit.`,
|
|
534
559
|
);
|
|
535
560
|
lines.push("");
|
|
536
561
|
|