ucode-agent 1.54.0 → 1.57.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/README.md +17 -6
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/package.json +64 -65
- package/src/core/loop.js +3112 -3240
- package/src/core/provider.js +1149 -1099
- package/src/core/skills.js +1 -1
- package/src/core/window.js +84 -11
- package/src/tools/files.js +113 -20
- package/src/tools/fuzzy.js +295 -0
- package/src/tools/index.js +6 -45
- package/src/tools/scaffold.js +502 -528
- package/src/tools/shared.js +12 -5
- package/src/tools/shell.js +52 -2
- package/src/ui/plain.js +8 -2
- package/src/ui/screen.js +66 -16
- package/src/ui/theme.js +22 -2
package/src/core/skills.js
CHANGED
|
@@ -172,7 +172,7 @@ export function autoLoadFor(skills, text) {
|
|
|
172
172
|
export function skillMessage(skill, { automatic = false, short = false } = {}) {
|
|
173
173
|
const digest = short && skill.digest ? skill.digest : null;
|
|
174
174
|
const why = automatic
|
|
175
|
-
? `The "${skill.name}" skill was loaded automatically because
|
|
175
|
+
? `The "${skill.name}" skill was loaded automatically because the request that follows is the kind it covers.`
|
|
176
176
|
: `The "${skill.name}" skill was loaded for this task.`;
|
|
177
177
|
return {
|
|
178
178
|
role: 'system',
|
package/src/core/window.js
CHANGED
|
@@ -87,10 +87,12 @@ function cutPoint(messages, budget) {
|
|
|
87
87
|
* @param {Array} messages
|
|
88
88
|
* @param {object} o
|
|
89
89
|
* @param {number} o.limit token budget
|
|
90
|
-
* @param {Function} o.summarize async (older) => string
|
|
90
|
+
* @param {Function} o.summarize async (older, previousSummary) => string
|
|
91
|
+
* @param {boolean} [o.force] fold even below the threshold — the provider
|
|
92
|
+
* has already said the request is too big
|
|
91
93
|
*/
|
|
92
|
-
export async function fold(messages, { limit, summarize }) {
|
|
93
|
-
if (!tooBig(messages, limit)) return { messages, folded: false };
|
|
94
|
+
export async function fold(messages, { limit, summarize, force = false }) {
|
|
95
|
+
if (!force && !tooBig(messages, limit)) return { messages, folded: false };
|
|
94
96
|
|
|
95
97
|
// Half the size that triggered the fold, so there is room to work before
|
|
96
98
|
// the next one. Sized off the same absolute rule, or a fold on a
|
|
@@ -103,7 +105,10 @@ export async function fold(messages, { limit, summarize }) {
|
|
|
103
105
|
// Nothing old enough to fold — the tail on its own is already oversized.
|
|
104
106
|
if (older.length === 0) return { messages, folded: false };
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
// A second fold must not summarize the first summary as if it were chat:
|
|
109
|
+
// it is handed over as the prior summary, to be merged rather than retold.
|
|
110
|
+
const previous = older.find((m) => m.folded)?.summary ?? null;
|
|
111
|
+
const summary = await summarize(older.filter((m) => !m.folded), previous);
|
|
107
112
|
|
|
108
113
|
return {
|
|
109
114
|
folded: true,
|
|
@@ -117,19 +122,87 @@ export async function fold(messages, { limit, summarize }) {
|
|
|
117
122
|
`were folded away to stay inside the context window.\n\n${summary}\n\n` +
|
|
118
123
|
'Treat all of that as settled context. Everything after this point is verbatim.',
|
|
119
124
|
folded: true,
|
|
125
|
+
summary,
|
|
120
126
|
},
|
|
121
127
|
...recent,
|
|
122
128
|
],
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
/**
|
|
133
|
+
* What the summarizer is asked to do: opencode's anchored summary (MIT, see
|
|
134
|
+
* THIRD_PARTY_NOTICES.md). Fixed sections mean nothing gets dropped because
|
|
135
|
+
* the summarizer found it dull — the next step and the files that matter
|
|
136
|
+
* always have a place — and a second fold merges into the first instead of
|
|
137
|
+
* summarizing a summary.
|
|
138
|
+
*/
|
|
139
|
+
export const SUMMARY_PROMPT = `You summarize a coding session so another coding agent can continue the work with nothing else to go on.
|
|
140
|
+
|
|
141
|
+
Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
|
142
|
+
<template>
|
|
143
|
+
## Objective
|
|
144
|
+
- [one or two brief sentences describing what the user is trying to accomplish]
|
|
145
|
+
|
|
146
|
+
## Important Details
|
|
147
|
+
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
|
|
148
|
+
|
|
149
|
+
## Work State
|
|
150
|
+
### Completed
|
|
151
|
+
- [finished work, verified facts, or changes made; otherwise "(none)"]
|
|
152
|
+
|
|
153
|
+
### Active
|
|
154
|
+
- [current work, partial changes, or investigation state; otherwise "(none)"]
|
|
155
|
+
|
|
156
|
+
### Blocked
|
|
157
|
+
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
|
158
|
+
|
|
159
|
+
## Next Move
|
|
160
|
+
1. [immediate concrete action, or "(none)"]
|
|
161
|
+
2. [next action if known, or "(none)"]
|
|
162
|
+
|
|
163
|
+
## Relevant Files
|
|
164
|
+
- [file or directory path: why it matters, or "(none)"]
|
|
165
|
+
</template>
|
|
166
|
+
|
|
167
|
+
Rules:
|
|
168
|
+
- Keep every section, even when empty.
|
|
169
|
+
- Use terse bullets, not prose paragraphs.
|
|
170
|
+
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
|
|
171
|
+
- Do not mention the summary process or that context was compacted.`;
|
|
172
|
+
|
|
173
|
+
const MERGE = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
|
|
174
|
+
|
|
175
|
+
When combining:
|
|
176
|
+
- Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
|
|
177
|
+
- The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
|
|
178
|
+
- Add new progress, decisions, constraints, and context from the conversation.
|
|
179
|
+
- Move completed work from "Active" to "Completed".
|
|
180
|
+
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
|
|
181
|
+
- Update "Objective" and "Next Move" to reflect the current work state.`;
|
|
182
|
+
|
|
183
|
+
/** The summarizer's user message: the conversation, and the summary it extends if there is one. */
|
|
184
|
+
export function summaryRequest(conversation, previous = null) {
|
|
185
|
+
// File contents are in there too. One carrying "</conversation>" must not
|
|
186
|
+
// close the section early and speak to the summarizer as if it were us.
|
|
187
|
+
const fence = (s) => String(s).replace(/<\/?(?:conversation|prior-summary)>/gi, '');
|
|
188
|
+
conversation = fence(conversation);
|
|
189
|
+
if (previous) previous = fence(previous);
|
|
190
|
+
const parts = [`Here is the conversation so far:
|
|
191
|
+
|
|
192
|
+
<conversation>
|
|
193
|
+
${conversation}
|
|
194
|
+
</conversation>`];
|
|
195
|
+
if (previous) {
|
|
196
|
+
parts.push(`Here is the summary of the conversation before the <conversation> above:
|
|
197
|
+
|
|
198
|
+
<prior-summary>
|
|
199
|
+
${previous}
|
|
200
|
+
</prior-summary>`, MERGE);
|
|
201
|
+
} else {
|
|
202
|
+
parts.push('Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.');
|
|
203
|
+
}
|
|
204
|
+
return parts.join('\n\n');
|
|
205
|
+
}
|
|
133
206
|
|
|
134
207
|
/**
|
|
135
208
|
* Flatten the messages being folded into plain text for the summarizer.
|
package/src/tools/files.js
CHANGED
|
@@ -7,18 +7,39 @@
|
|
|
7
7
|
* coding agent can do, because everything after it is built on a lie.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
11
11
|
import { remember } from '../core/undo.js';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { ToolFailure } from '../core/failure.js';
|
|
14
14
|
import {
|
|
15
15
|
resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
|
|
16
16
|
changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
|
|
17
|
-
noteFile, writeTracked, assertUnchanged,
|
|
17
|
+
noteFile, writeTracked, assertUnchanged, getRoot,
|
|
18
18
|
} from './shared.js';
|
|
19
19
|
import { packageJsonWritten } from './shell.js';
|
|
20
|
+
import { fuzzyReplace } from './fuzzy.js';
|
|
20
21
|
import { parse as parseSource } from '@babel/parser';
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Up to three names beside a missing file that look like what was meant —
|
|
25
|
+
* "App.jsx" for "app.jsx", "index.html" for "index.htm". Named in the refusal
|
|
26
|
+
* (as opencode's read tool does), the next step is the right read instead of
|
|
27
|
+
* a list_dir to find out.
|
|
28
|
+
*/
|
|
29
|
+
async function lookalikes(target) {
|
|
30
|
+
const dir = path.dirname(target.abs);
|
|
31
|
+
const base = path.basename(target.abs).toLowerCase();
|
|
32
|
+
const shown = path.dirname(target.show);
|
|
33
|
+
try {
|
|
34
|
+
return (await fs.readdir(dir))
|
|
35
|
+
.filter((n) => n.toLowerCase().includes(base) || (n.length > 2 && base.includes(n.toLowerCase())))
|
|
36
|
+
.slice(0, 3)
|
|
37
|
+
.map((n) => (shown === '.' ? n : `${shown}/${n}`));
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
23
44
|
const target = resolveIn(p, 'read_file');
|
|
24
45
|
await guard(target, `read ${target.abs}`);
|
|
@@ -28,7 +49,12 @@ export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
|
28
49
|
try {
|
|
29
50
|
stat = await fs.stat(target.abs);
|
|
30
51
|
} catch (err) {
|
|
31
|
-
|
|
52
|
+
const failure = fsFailure(err, attempted, target.show);
|
|
53
|
+
// Outside the project the user said yes to one path, not to a listing of
|
|
54
|
+
// the folder around it — so no lookalikes there.
|
|
55
|
+
const near = err?.code === 'ENOENT' && target.inside ? await lookalikes(target) : [];
|
|
56
|
+
if (near.length) failure.fix = `Did you mean ${near.join(', ')}? ${failure.fix}`;
|
|
57
|
+
throw failure;
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
if (stat.isDirectory()) {
|
|
@@ -170,6 +196,44 @@ const brokenNote = (show, problem) => {
|
|
|
170
196
|
`a structure you have lost track of.`;
|
|
171
197
|
};
|
|
172
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A module in a page with no build step that imports a stylesheet or an image.
|
|
201
|
+
*
|
|
202
|
+
* A bundler would take it; a browser refuses the whole module ("Failed to
|
|
203
|
+
* load module script ... MIME type text/css"), so none of the app's script
|
|
204
|
+
* runs and every button is dead. The syntax is fine, so nothing else notices
|
|
205
|
+
* until the page is opened — a live build shipped exactly this as done.
|
|
206
|
+
*/
|
|
207
|
+
const ASSET_IMPORT = /^[ \t]*import\s+(?:[\w$*{}\s,]+?\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less|svg|png|jpe?g|gif|webp))['"](?!\s*(?:with|assert)\s*\{)/m;
|
|
208
|
+
|
|
209
|
+
export function assetImport(abs, text) {
|
|
210
|
+
if (!/\.m?js$/i.test(abs)) return null;
|
|
211
|
+
// An import quoted inside a block comment is not an import.
|
|
212
|
+
const hit = ASSET_IMPORT.exec(String(text).replace(/\/\*[\s\S]*?\*\//g, ''));
|
|
213
|
+
if (!hit) return null;
|
|
214
|
+
// Anything with a package.json above it, up to the project root, may well be
|
|
215
|
+
// bundled. path.relative, not a string prefix: "ucode2" starts with "ucode",
|
|
216
|
+
// and Windows paths differ in case from one shell to the next.
|
|
217
|
+
const top = getRoot();
|
|
218
|
+
const within = (dir) => {
|
|
219
|
+
const rel = path.relative(top, dir);
|
|
220
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
221
|
+
};
|
|
222
|
+
for (let dir = path.dirname(abs); ; dir = path.dirname(dir)) {
|
|
223
|
+
if (existsSync(path.join(dir, 'package.json'))) return null;
|
|
224
|
+
if (!within(dir) || path.relative(top, dir) === '' || path.dirname(dir) === dir) break;
|
|
225
|
+
}
|
|
226
|
+
return `imports ${hit[1]}, which a page with no build step cannot do: the browser refuses the ` +
|
|
227
|
+
'whole module, so none of its script runs. Remove that import and load it from the page ' +
|
|
228
|
+
'instead — <link rel="stylesheet" href="…"> for a stylesheet, an <img> or a plain URL for an image.';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The note for that, appended to a write's result like a parse problem is. */
|
|
232
|
+
const pageNote = (target, text) => {
|
|
233
|
+
const problem = assetImport(target.abs, text);
|
|
234
|
+
return problem ? `\n\n⚠ ${target.show} ${problem}` : '';
|
|
235
|
+
};
|
|
236
|
+
|
|
173
237
|
/** A file this short comes back whole after an edit; longer ones show the part around the change. */
|
|
174
238
|
const SHOW_WHOLE = 250;
|
|
175
239
|
const AROUND = 15;
|
|
@@ -323,6 +387,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
|
|
|
323
387
|
lineCount,
|
|
324
388
|
diff,
|
|
325
389
|
problem: syntaxProblem(target.abs, content),
|
|
390
|
+
note: pageNote(target, content),
|
|
326
391
|
line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
|
|
327
392
|
`(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
|
|
328
393
|
};
|
|
@@ -342,7 +407,7 @@ export async function writeFile({ path: p, content }) {
|
|
|
342
407
|
|
|
343
408
|
const written = await put(target, content);
|
|
344
409
|
const out = result(
|
|
345
|
-
`${written.line}.${parseNote(target.show, written.problem)}`,
|
|
410
|
+
`${written.line}.${parseNote(target.show, written.problem)}${written.note}`,
|
|
346
411
|
`${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
|
|
347
412
|
);
|
|
348
413
|
out.diff = written.diff;
|
|
@@ -388,7 +453,7 @@ export async function batchWrite({ files }) {
|
|
|
388
453
|
// would bury the reply under three hundred lines of gutter.
|
|
389
454
|
const written = await put(target, content, { diffMax: 6 });
|
|
390
455
|
if (!written.existed) created++;
|
|
391
|
-
lines.push(written.line + parseNote(target.show, written.problem));
|
|
456
|
+
lines.push(written.line + parseNote(target.show, written.problem) + written.note);
|
|
392
457
|
if (written.problem) broken++;
|
|
393
458
|
diff.push(`~${target.show}`, ...written.diff);
|
|
394
459
|
}
|
|
@@ -444,7 +509,7 @@ function explainMiss(original, oldString, show) {
|
|
|
444
509
|
}
|
|
445
510
|
|
|
446
511
|
/** Apply one replacement to a string, or explain precisely why it cannot. */
|
|
447
|
-
function replaceOnce(text, { old_string, new_string }, { show, attempted, label = '' }) {
|
|
512
|
+
function replaceOnce(text, { old_string, new_string, replace_all }, { show, attempted, label = '' }) {
|
|
448
513
|
const prefix = label ? `${label}: ` : '';
|
|
449
514
|
|
|
450
515
|
if (typeof old_string !== 'string' || typeof new_string !== 'string') {
|
|
@@ -467,7 +532,7 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
|
|
|
467
532
|
// for exactly this loop. Saying "already done" ends it in one step.
|
|
468
533
|
if (old_string === new_string) {
|
|
469
534
|
const found = text.indexOf(old_string);
|
|
470
|
-
return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length,
|
|
535
|
+
return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, how: '', count: 0 };
|
|
471
536
|
}
|
|
472
537
|
|
|
473
538
|
// Models write \n. A file checked out on Windows is often \r\n, and then an
|
|
@@ -486,21 +551,45 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
|
|
|
486
551
|
detail: { hits },
|
|
487
552
|
});
|
|
488
553
|
|
|
554
|
+
// replace_all is for renames: every copy changes, and many copies is the point.
|
|
555
|
+
const all = replace_all === true || replace_all === 'true';
|
|
489
556
|
const hits = text.split(oldText).length - 1;
|
|
490
|
-
if (hits > 1) throw ambiguous(hits);
|
|
491
|
-
if (hits
|
|
557
|
+
if (hits > 1 && !all) throw ambiguous(hits);
|
|
558
|
+
if (hits >= 1) {
|
|
492
559
|
const at = text.slice(0, text.indexOf(oldText)).split(/\r?\n/).length;
|
|
493
|
-
|
|
560
|
+
const out = all ? text.split(oldText).join(newText) : text.replace(oldText, () => newText);
|
|
561
|
+
return { text: out, at, how: '', count: hits };
|
|
494
562
|
}
|
|
495
563
|
|
|
496
564
|
// No exact match. The commonest reason by far is whitespace — tabs against
|
|
497
565
|
// spaces, a different indent depth, trailing spaces — with every word right.
|
|
498
566
|
// Match line by line ignoring that, and re-indent the replacement to fit.
|
|
499
567
|
// Still unique or nothing: a loose match found twice is refused like any other.
|
|
500
|
-
const loose = looseReplace(text, old_string, new_string);
|
|
501
|
-
if (loose?.count === 1)
|
|
568
|
+
const loose = all ? null : looseReplace(text, old_string, new_string);
|
|
569
|
+
if (loose?.count === 1) {
|
|
570
|
+
return { text: loose.text, at: loose.at, how: 'ignoring whitespace and re-indented to fit', count: 1 };
|
|
571
|
+
}
|
|
502
572
|
if (loose?.count > 1) throw ambiguous(loose.count, ' once whitespace is ignored');
|
|
503
573
|
|
|
574
|
+
// Still nothing. The remaining slips — a middle line remembered slightly
|
|
575
|
+
// wrong, escapes written out, a blank line at either end — each have a
|
|
576
|
+
// matcher of their own (fuzzy.js). They are given the edit in the file's
|
|
577
|
+
// own line endings, and only the matched span changes: the rest of a file
|
|
578
|
+
// with mixed endings keeps every one it had.
|
|
579
|
+
const fuzzy = fuzzyReplace(text, oldText, newText, { all });
|
|
580
|
+
if (fuzzy?.ambiguous) throw ambiguous('several', ' once matched loosely');
|
|
581
|
+
if (fuzzy?.wide) {
|
|
582
|
+
throw new ToolFailure({
|
|
583
|
+
kind: 'no_match', attempted,
|
|
584
|
+
failed: `${prefix}old_string only matches ${show} loosely, across far more text than it contains. Refusing to replace that much.`,
|
|
585
|
+
fix: `Read ${show} again and copy the exact text you mean to replace.`,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
if (fuzzy) {
|
|
589
|
+
const at = text.slice(0, fuzzy.index).split('\n').length;
|
|
590
|
+
return { text: fuzzy.text, at, how: fuzzy.how, count: fuzzy.count };
|
|
591
|
+
}
|
|
592
|
+
|
|
504
593
|
const { failed, fix } = explainMiss(text, old_string, show);
|
|
505
594
|
throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
|
|
506
595
|
}
|
|
@@ -548,7 +637,7 @@ function looseReplace(text, oldString, newString) {
|
|
|
548
637
|
return { count: 1, text: out.join(eol), at: start + 1 };
|
|
549
638
|
}
|
|
550
639
|
|
|
551
|
-
export async function editFile({ path: p, old_string, new_string }) {
|
|
640
|
+
export async function editFile({ path: p, old_string, new_string, replace_all }) {
|
|
552
641
|
const target = resolveIn(p, 'edit_file');
|
|
553
642
|
const attempted = `editing ${target.show}`;
|
|
554
643
|
await guard(target, `edit ${target.abs}`);
|
|
@@ -560,7 +649,7 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
560
649
|
throw fsFailure(err, attempted, target.show);
|
|
561
650
|
}
|
|
562
651
|
|
|
563
|
-
const { text, at,
|
|
652
|
+
const { text, at, how, count } = replaceOnce(original, { old_string, new_string, replace_all }, {
|
|
564
653
|
show: target.show, attempted,
|
|
565
654
|
});
|
|
566
655
|
|
|
@@ -574,14 +663,18 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
574
663
|
|
|
575
664
|
const delta = toLines(text).length - toLines(original).length;
|
|
576
665
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
577
|
-
const
|
|
666
|
+
const where = count > 1
|
|
667
|
+
? `${count} occurrences in ${target.show}, the first at line ${at}`
|
|
668
|
+
: `one occurrence in ${target.show} at line ${at}`;
|
|
669
|
+
const matched = how ? `, matched ${how}` : '';
|
|
578
670
|
|
|
579
671
|
const span = toLines(new_string).length;
|
|
580
672
|
const out = result(
|
|
581
|
-
`Replaced
|
|
582
|
-
parseNote(target.show, syntaxProblem(target.abs, text)) +
|
|
673
|
+
`Replaced ${where} (${change}${matched}).` +
|
|
674
|
+
parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
|
|
583
675
|
nowReads(target.show, text, at, span),
|
|
584
|
-
|
|
676
|
+
`${count > 1 ? `${count} changes from line` : '1 change at line'} ${at} · ${change}` +
|
|
677
|
+
`${how ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
|
|
585
678
|
MAX_FILE_OUTPUT
|
|
586
679
|
);
|
|
587
680
|
// The replacement is diffed on its own and offset to where it landed, so
|
|
@@ -651,7 +744,7 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
651
744
|
|
|
652
745
|
const out = result(
|
|
653
746
|
`Applied ${edits.length} edits to ${target.show} (${change}).` +
|
|
654
|
-
parseNote(target.show, syntaxProblem(target.abs, text)) +
|
|
747
|
+
parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
|
|
655
748
|
nowReads(target.show, text, 1, toLines(text).length),
|
|
656
749
|
`${edits.length} edits · ${change}`,
|
|
657
750
|
MAX_FILE_OUTPUT
|
|
@@ -741,7 +834,7 @@ export async function editFiles({ files }) {
|
|
|
741
834
|
planned.map((p) => {
|
|
742
835
|
const problem = syntaxProblem(p.target.abs, p.text);
|
|
743
836
|
return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
|
|
744
|
-
parseNote(p.target.show, problem);
|
|
837
|
+
parseNote(p.target.show, problem) + pageNote(p.target, p.text);
|
|
745
838
|
}).join('\n'),
|
|
746
839
|
`${planned.length} files · ${edits} edits`
|
|
747
840
|
);
|