ucode-agent 1.1.0 → 1.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/README.md +86 -29
- package/package.json +3 -4
- package/skills/ai-features/SKILL.md +140 -0
- package/skills/build-app/SKILL.md +3 -2
- package/skills/performance/SKILL.md +84 -0
- package/skills/refactor/SKILL.md +72 -0
- package/skills/security/SKILL.md +110 -0
- package/src/core/context.js +151 -0
- package/src/core/loop.js +376 -25
- package/src/core/provider.js +39 -22
- package/src/core/version.js +16 -0
- package/src/tools/files.js +173 -16
- package/src/tools/index.js +445 -394
- package/src/tools/shell.js +141 -0
- package/src/ui/plain.js +330 -325
- package/src/ui/screen.js +1193 -1094
- package/src/ui/theme.js +18 -0
- package/ucode.js +3 -4
package/src/core/provider.js
CHANGED
|
@@ -55,28 +55,28 @@ dotenv.config({ path: join(PACKAGE_ROOT, '.env'), quiet: true });
|
|
|
55
55
|
*/
|
|
56
56
|
export const MODELS = {
|
|
57
57
|
'nvidia/nemotron-3-ultra-550b-a55b:free': {
|
|
58
|
-
name: 'Nemotron 3 Ultra
|
|
58
|
+
name: 'Nemotron 3 Ultra',
|
|
59
59
|
context: 1_000_000,
|
|
60
60
|
star: true,
|
|
61
61
|
note: 'deepest reasoning, 1M context — the default',
|
|
62
62
|
},
|
|
63
63
|
'nvidia/nemotron-3.5-lightning:free': {
|
|
64
|
-
name: 'Nemotron 3.5 Lightning
|
|
64
|
+
name: 'Nemotron 3.5 Lightning',
|
|
65
65
|
context: 1_000_000,
|
|
66
66
|
note: 'same huge window, answers much sooner',
|
|
67
67
|
},
|
|
68
68
|
'nvidia/nemotron-3-super-120b-a12b:free': {
|
|
69
|
-
name: 'Nemotron 3 Super
|
|
69
|
+
name: 'Nemotron 3 Super',
|
|
70
70
|
context: 262_144,
|
|
71
71
|
note: 'strong all-rounder, quick to first token',
|
|
72
72
|
},
|
|
73
73
|
'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free': {
|
|
74
|
-
name: 'Nemotron 3 Nano Omni
|
|
74
|
+
name: 'Nemotron 3 Nano Omni',
|
|
75
75
|
context: 256_000,
|
|
76
76
|
note: 'small and fast, reasoning tuned',
|
|
77
77
|
},
|
|
78
78
|
'cohere/north-mini-code:free': {
|
|
79
|
-
name: 'North Mini Code
|
|
79
|
+
name: 'North Mini Code',
|
|
80
80
|
context: 256_000,
|
|
81
81
|
star: true,
|
|
82
82
|
note: 'code and UI specialist — reach for it on frontend work',
|
|
@@ -121,7 +121,7 @@ export function setModel(id) {
|
|
|
121
121
|
return current;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/** The short name for a model id: "Nemotron 3 Ultra
|
|
124
|
+
/** The short name for a model id: "Nemotron 3 Ultra". */
|
|
125
125
|
export function modelName(id = current) {
|
|
126
126
|
return MODELS[id]?.name ?? id;
|
|
127
127
|
}
|
|
@@ -179,16 +179,18 @@ export function estimateConversation(messages) {
|
|
|
179
179
|
* of first-run convenience is worth handing out a live credential.
|
|
180
180
|
*/
|
|
181
181
|
function apiKey() {
|
|
182
|
-
|
|
182
|
+
// UCODE_API_KEY is the documented name. The provider's own variable name is
|
|
183
|
+
// still read, so a key set up for another tool keeps working here.
|
|
184
|
+
const key = (process.env.UCODE_API_KEY || process.env.OPENROUTER_API_KEY || '').trim();
|
|
183
185
|
if (!key) {
|
|
184
186
|
throw new Failure({
|
|
185
187
|
kind: 'no_api_key',
|
|
186
|
-
attempted: 'connecting to
|
|
187
|
-
failed: '
|
|
188
|
+
attempted: 'connecting to the model',
|
|
189
|
+
failed: 'No API key is set - UCODE_API_KEY is missing from the environment and from every .env file.',
|
|
188
190
|
fix:
|
|
189
|
-
`Put
|
|
191
|
+
`Put UCODE_API_KEY=your-key in ${ENV_FILE} — that applies to every ` +
|
|
190
192
|
'project on this machine — or in a .env file beside your code. ' +
|
|
191
|
-
'
|
|
193
|
+
'Free keys: https://openrouter.ai/keys',
|
|
192
194
|
});
|
|
193
195
|
}
|
|
194
196
|
return key;
|
|
@@ -349,10 +351,10 @@ export function explain(err, id) {
|
|
|
349
351
|
return new Failure({
|
|
350
352
|
kind: 'invalid_api_key',
|
|
351
353
|
attempted,
|
|
352
|
-
failed: `
|
|
354
|
+
failed: `The API key was rejected (HTTP ${status ?? 401}).`,
|
|
353
355
|
fix:
|
|
354
|
-
'Check
|
|
355
|
-
'is still active
|
|
356
|
+
'Check UCODE_API_KEY in ~/.ucode/.env for a typo or trailing space, and ' +
|
|
357
|
+
'confirm the key is still active in your account.',
|
|
356
358
|
cause: err,
|
|
357
359
|
});
|
|
358
360
|
}
|
|
@@ -380,8 +382,8 @@ export function explain(err, id) {
|
|
|
380
382
|
? `The free daily request cap for ${modelName(id)} is used up.`
|
|
381
383
|
: `Too many requests for ${modelName(id)} just now${wait ? ` — clear in ${wait}` : ''}.`,
|
|
382
384
|
fix: daily
|
|
383
|
-
? 'Free caps reset each day. /model switches to another one, or add credit
|
|
384
|
-
'
|
|
385
|
+
? 'Free caps reset each day. /model switches to another one, or add credit to ' +
|
|
386
|
+
'your account to lift the ceiling.'
|
|
385
387
|
: 'ucode waits these out on its own. Free endpoints are shared, so it usually ' +
|
|
386
388
|
'clears in seconds; /model moves to a quieter one.',
|
|
387
389
|
detail: { retryAfter, daily },
|
|
@@ -406,7 +408,7 @@ export function explain(err, id) {
|
|
|
406
408
|
return new Failure({
|
|
407
409
|
kind: 'bad_model',
|
|
408
410
|
attempted,
|
|
409
|
-
failed: `
|
|
411
|
+
failed: `No model "${id}" is available to this key.`,
|
|
410
412
|
fix: `Run /model. ucode ships with: ${Object.keys(MODELS).join(', ')}`,
|
|
411
413
|
cause: err,
|
|
412
414
|
});
|
|
@@ -435,7 +437,7 @@ export function explain(err, id) {
|
|
|
435
437
|
attempted,
|
|
436
438
|
failed: noTools
|
|
437
439
|
? `${modelName(id)} cannot call tools, which ucode needs for every task.`
|
|
438
|
-
: `
|
|
440
|
+
: `The request was rejected as malformed (HTTP 400): ${detail}`,
|
|
439
441
|
fix: noTools
|
|
440
442
|
? 'Run /model and pick another one.'
|
|
441
443
|
: 'Usually an oversized conversation. /new starts a fresh one.',
|
|
@@ -497,7 +499,7 @@ export function explain(err, id) {
|
|
|
497
499
|
return new Failure({
|
|
498
500
|
kind: 'network',
|
|
499
501
|
attempted,
|
|
500
|
-
failed: `The connection to
|
|
502
|
+
failed: `The connection to the model dropped: ${raw}`,
|
|
501
503
|
fix:
|
|
502
504
|
'ucode retries this by itself. If it keeps happening, check your connection, ' +
|
|
503
505
|
'VPN and any corporate proxy (HTTPS_PROXY) — or /model to a lighter one, since ' +
|
|
@@ -628,6 +630,8 @@ async function streamed(request, opts, id) {
|
|
|
628
630
|
let finishReason = 'stop';
|
|
629
631
|
let usage = null;
|
|
630
632
|
const partial = new Map();
|
|
633
|
+
const handed = new Set();
|
|
634
|
+
let highest = -1;
|
|
631
635
|
|
|
632
636
|
for await (const chunk of stream) {
|
|
633
637
|
if (opts.signal?.aborted) break;
|
|
@@ -654,6 +658,20 @@ async function streamed(request, opts, id) {
|
|
|
654
658
|
// A tool call's name and arguments arrive across several chunks, keyed by
|
|
655
659
|
// index, so they are stitched back together here.
|
|
656
660
|
for (const call of delta.tool_calls ?? []) {
|
|
661
|
+
// Calls arrive one after another, so the first chunk of call N means
|
|
662
|
+
// every call before it is complete. Those are handed over at once, and
|
|
663
|
+
// the caller can start running them while the rest are still being
|
|
664
|
+
// written — the reply streaming and the tools working overlap.
|
|
665
|
+
if (opts.onToolCall && call.index > highest) {
|
|
666
|
+
for (const [index, slot] of partial) {
|
|
667
|
+
if (index < call.index && !handed.has(index)) {
|
|
668
|
+
handed.add(index);
|
|
669
|
+
opts.onToolCall(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
highest = call.index;
|
|
673
|
+
}
|
|
674
|
+
|
|
657
675
|
const slot = partial.get(call.index) ?? { id: '', name: '', args: '' };
|
|
658
676
|
if (call.id) slot.id = call.id;
|
|
659
677
|
if (call.function?.name) slot.name += call.function.name;
|
|
@@ -663,9 +681,8 @@ async function streamed(request, opts, id) {
|
|
|
663
681
|
}
|
|
664
682
|
|
|
665
683
|
const toolCalls = [];
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
toolCalls.push(readCall({ id: slot.id || `call_${n++}`, name: slot.name, raw: slot.args }));
|
|
684
|
+
for (const [index, slot] of partial) {
|
|
685
|
+
toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
|
|
669
686
|
}
|
|
670
687
|
|
|
671
688
|
return {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version.js — the version, read from package.json rather than typed twice.
|
|
3
|
+
*
|
|
4
|
+
* package.json always ships in an npm package, so this is right wherever ucode
|
|
5
|
+
* is installed, and it can never drift from what `npm publish` released.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
export const VERSION = (() => {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
13
|
+
} catch {
|
|
14
|
+
return '';
|
|
15
|
+
}
|
|
16
|
+
})();
|
package/src/tools/files.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
|
|
15
15
|
changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
|
|
16
16
|
} from './shared.js';
|
|
17
|
+
import { packageJsonWritten } from './shell.js';
|
|
17
18
|
|
|
18
19
|
export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
19
20
|
const target = resolveIn(p, 'read_file');
|
|
@@ -84,6 +85,15 @@ export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Everything that writes a file ends here. A package.json with dependencies
|
|
90
|
+
* starts its install in the background at once, while the rest of the app is
|
|
91
|
+
* still being written.
|
|
92
|
+
*/
|
|
93
|
+
function written(target, content) {
|
|
94
|
+
if (path.basename(target.abs) === 'package.json') packageJsonWritten(target.abs, content);
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
/** At most this many files in one read_files call. */
|
|
88
98
|
const MAX_BATCH = 20;
|
|
89
99
|
|
|
@@ -194,6 +204,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
|
|
|
194
204
|
} catch (err) {
|
|
195
205
|
throw fsFailure(err, attempted, target.show);
|
|
196
206
|
}
|
|
207
|
+
written(target, content);
|
|
197
208
|
|
|
198
209
|
const existed = previous !== null;
|
|
199
210
|
const lineCount = content === '' ? 0 : toLines(content).length;
|
|
@@ -346,23 +357,82 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
|
|
|
346
357
|
});
|
|
347
358
|
}
|
|
348
359
|
|
|
349
|
-
|
|
360
|
+
// Models write \n. A file checked out on Windows is often \r\n, and then an
|
|
361
|
+
// otherwise perfect old_string can never match. Speak the file's dialect.
|
|
362
|
+
let oldText = old_string;
|
|
363
|
+
let newText = new_string;
|
|
364
|
+
if (text.includes('\r\n') && !oldText.includes('\r')) {
|
|
365
|
+
oldText = oldText.replace(/\r?\n/g, '\r\n');
|
|
366
|
+
newText = newText.replace(/\r?\n/g, '\r\n');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const ambiguous = (hits, how = '') => new ToolFailure({
|
|
370
|
+
kind: 'ambiguous', attempted,
|
|
371
|
+
failed: `${prefix}old_string appears ${hits} times in ${show}${how}. Refusing to guess which one you meant.`,
|
|
372
|
+
fix: 'Add surrounding lines to old_string until it matches exactly one place.',
|
|
373
|
+
detail: { hits },
|
|
374
|
+
});
|
|
350
375
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
376
|
+
const hits = text.split(oldText).length - 1;
|
|
377
|
+
if (hits > 1) throw ambiguous(hits);
|
|
378
|
+
if (hits === 1) {
|
|
379
|
+
const at = text.slice(0, text.indexOf(oldText)).split(/\r?\n/).length;
|
|
380
|
+
return { text: text.replace(oldText, () => newText), at, loose: false };
|
|
354
381
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
382
|
+
|
|
383
|
+
// No exact match. The commonest reason by far is whitespace — tabs against
|
|
384
|
+
// spaces, a different indent depth, trailing spaces — with every word right.
|
|
385
|
+
// Match line by line ignoring that, and re-indent the replacement to fit.
|
|
386
|
+
// Still unique or nothing: a loose match found twice is refused like any other.
|
|
387
|
+
const loose = looseReplace(text, old_string, new_string);
|
|
388
|
+
if (loose?.count === 1) return { text: loose.text, at: loose.at, loose: true };
|
|
389
|
+
if (loose?.count > 1) throw ambiguous(loose.count, ' once whitespace is ignored');
|
|
390
|
+
|
|
391
|
+
const { failed, fix } = explainMiss(text, old_string, show);
|
|
392
|
+
throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Find old_string by its lines' content alone and swap in new_string, indented
|
|
397
|
+
* the way the file is indented at that spot. Returns { count } when it finds
|
|
398
|
+
* none or several, and { count: 1, text, at } when it finds exactly one.
|
|
399
|
+
*/
|
|
400
|
+
function looseReplace(text, oldString, newString) {
|
|
401
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
402
|
+
const lines = text.split(/\r?\n/);
|
|
403
|
+
|
|
404
|
+
const want = oldString.replace(/\r/g, '').split('\n');
|
|
405
|
+
while (want.length > 1 && !want[want.length - 1].trim()) want.pop();
|
|
406
|
+
while (want.length > 1 && !want[0].trim()) want.shift();
|
|
407
|
+
const target = want.map((l) => l.trim());
|
|
408
|
+
if (target.every((t) => !t)) return null;
|
|
409
|
+
|
|
410
|
+
const starts = [];
|
|
411
|
+
for (let i = 0; i + want.length <= lines.length; i++) {
|
|
412
|
+
let same = true;
|
|
413
|
+
for (let j = 0; j < want.length; j++) {
|
|
414
|
+
if (lines[i + j].trim() !== target[j]) { same = false; break; }
|
|
415
|
+
}
|
|
416
|
+
if (same) starts.push(i);
|
|
362
417
|
}
|
|
418
|
+
if (starts.length !== 1) return { count: starts.length };
|
|
419
|
+
|
|
420
|
+
const start = starts[0];
|
|
421
|
+
const indent = (l) => /^[ \t]*/.exec(l)[0];
|
|
422
|
+
const first = want.findIndex((l) => l.trim());
|
|
423
|
+
const fileIndent = indent(lines[start + first]);
|
|
424
|
+
const wroteIndent = indent(want[first]);
|
|
425
|
+
|
|
426
|
+
const replacement = newString.replace(/\r/g, '').split('\n');
|
|
427
|
+
if (replacement.length > 1 && replacement[replacement.length - 1] === '') replacement.pop();
|
|
428
|
+
const reindented = replacement.map((l) => {
|
|
429
|
+
if (!l.trim()) return l.trim();
|
|
430
|
+
const body = l.startsWith(wroteIndent) ? l.slice(wroteIndent.length) : l.replace(/^[ \t]*/, '');
|
|
431
|
+
return fileIndent + body;
|
|
432
|
+
});
|
|
363
433
|
|
|
364
|
-
const
|
|
365
|
-
return { text:
|
|
434
|
+
const out = [...lines.slice(0, start), ...reindented, ...lines.slice(start + want.length)];
|
|
435
|
+
return { count: 1, text: out.join(eol), at: start + 1 };
|
|
366
436
|
}
|
|
367
437
|
|
|
368
438
|
export async function editFile({ path: p, old_string, new_string }) {
|
|
@@ -377,7 +447,7 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
377
447
|
throw fsFailure(err, attempted, target.show);
|
|
378
448
|
}
|
|
379
449
|
|
|
380
|
-
const { text, at } = replaceOnce(original, { old_string, new_string }, {
|
|
450
|
+
const { text, at, loose } = replaceOnce(original, { old_string, new_string }, {
|
|
381
451
|
show: target.show, attempted,
|
|
382
452
|
});
|
|
383
453
|
|
|
@@ -386,13 +456,15 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
386
456
|
} catch (err) {
|
|
387
457
|
throw fsFailure(err, attempted, target.show);
|
|
388
458
|
}
|
|
459
|
+
written(target, text);
|
|
389
460
|
|
|
390
461
|
const delta = toLines(text).length - toLines(original).length;
|
|
391
462
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
463
|
+
const how = loose ? ', matched ignoring whitespace and re-indented to fit' : '';
|
|
392
464
|
|
|
393
465
|
const out = result(
|
|
394
|
-
`Replaced one occurrence in ${target.show} at line ${at} (${change}).`,
|
|
395
|
-
`1 change at line ${at} · ${change}`
|
|
466
|
+
`Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).`,
|
|
467
|
+
`1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}`
|
|
396
468
|
);
|
|
397
469
|
// The replacement is diffed on its own and offset to where it landed, so
|
|
398
470
|
// the gutter shows the file's line numbers rather than 1, 2, 3.
|
|
@@ -453,6 +525,7 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
453
525
|
} catch (err) {
|
|
454
526
|
throw fsFailure(err, attempted, target.show);
|
|
455
527
|
}
|
|
528
|
+
written(target, text);
|
|
456
529
|
|
|
457
530
|
const delta = toLines(text).length - toLines(original).length;
|
|
458
531
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
@@ -464,3 +537,87 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
464
537
|
out.diff = diff;
|
|
465
538
|
return out;
|
|
466
539
|
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Exact replacements across several files in one call.
|
|
543
|
+
*
|
|
544
|
+
* A change that touches the route, the component and the type together is one
|
|
545
|
+
* round trip instead of three. Every edit in every file is applied to a copy
|
|
546
|
+
* in memory first; if any of them fails, nothing is written anywhere — a
|
|
547
|
+
* cross-file change that half landed leaves the project in a state that
|
|
548
|
+
* compiles nowhere.
|
|
549
|
+
*/
|
|
550
|
+
export async function editFiles({ files }) {
|
|
551
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
552
|
+
throw new ToolFailure({
|
|
553
|
+
kind: 'bad_args',
|
|
554
|
+
attempted: 'editing several files',
|
|
555
|
+
failed: 'The "files" argument must be a non-empty array.',
|
|
556
|
+
fix: 'Pass files as [{ path, edits: [{ old_string, new_string }, ...] }, ...].',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const planned = [];
|
|
561
|
+
const seen = new Set();
|
|
562
|
+
|
|
563
|
+
for (const [i, entry] of files.entries()) {
|
|
564
|
+
const target = resolveIn(entry?.path, 'edit_files');
|
|
565
|
+
const attempted = `editing ${target.show}`;
|
|
566
|
+
|
|
567
|
+
if (seen.has(target.abs)) {
|
|
568
|
+
throw new ToolFailure({
|
|
569
|
+
kind: 'bad_args', attempted,
|
|
570
|
+
failed: `${target.show} is listed twice.`,
|
|
571
|
+
fix: 'List each file once, with all of its edits together. Nothing was written.',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
seen.add(target.abs);
|
|
575
|
+
|
|
576
|
+
if (!Array.isArray(entry.edits) || entry.edits.length === 0) {
|
|
577
|
+
throw new ToolFailure({
|
|
578
|
+
kind: 'bad_args', attempted,
|
|
579
|
+
failed: `File ${i + 1} (${target.show}) has no edits.`,
|
|
580
|
+
fix: 'Give every file a non-empty edits array. Nothing was written.',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
await guard(target, `edit ${target.abs}`);
|
|
585
|
+
|
|
586
|
+
let original;
|
|
587
|
+
try {
|
|
588
|
+
original = await fs.readFile(target.abs, 'utf8');
|
|
589
|
+
} catch (err) {
|
|
590
|
+
throw fsFailure(err, attempted, target.show);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let text = original;
|
|
594
|
+
const diff = [`~${target.show}`];
|
|
595
|
+
for (const [j, edit] of entry.edits.entries()) {
|
|
596
|
+
const applied = replaceOnce(text, edit ?? {}, {
|
|
597
|
+
show: target.show,
|
|
598
|
+
attempted,
|
|
599
|
+
label: `${target.show}, edit ${j + 1} of ${entry.edits.length} (nothing was written)`,
|
|
600
|
+
});
|
|
601
|
+
diff.push(...renderDiff(changedRegion(edit.old_string, edit.new_string), { offset: applied.at - 1, max: 6 }));
|
|
602
|
+
text = applied.text;
|
|
603
|
+
}
|
|
604
|
+
planned.push({ target, text, diff, count: entry.edits.length });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const { target, text } of planned) {
|
|
608
|
+
try {
|
|
609
|
+
await fs.writeFile(target.abs, text, 'utf8');
|
|
610
|
+
} catch (err) {
|
|
611
|
+
throw fsFailure(err, `editing ${target.show}`, target.show);
|
|
612
|
+
}
|
|
613
|
+
written(target, text);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const edits = planned.reduce((n, p) => n + p.count, 0);
|
|
617
|
+
const out = result(
|
|
618
|
+
planned.map((p) => `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})`).join('\n'),
|
|
619
|
+
`${planned.length} files · ${edits} edits`
|
|
620
|
+
);
|
|
621
|
+
out.diff = planned.flatMap((p) => p.diff);
|
|
622
|
+
return out;
|
|
623
|
+
}
|