decorated-pi 0.5.3 → 0.5.5
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/extensions/file-times.ts +4 -4
- package/extensions/io.ts +7 -1
- package/extensions/patch.ts +560 -157
- package/extensions/session-title.ts +37 -12
- package/package.json +1 -1
package/extensions/file-times.ts
CHANGED
|
@@ -53,16 +53,16 @@ export function checkStaleFile(absPath: string, displayPath: string): string | u
|
|
|
53
53
|
if (lastRead === undefined) {
|
|
54
54
|
// File exists but never read — must read first to avoid blind edits
|
|
55
55
|
return (
|
|
56
|
-
`
|
|
57
|
-
`
|
|
56
|
+
`Please read the file with the read tool before editing. ` +
|
|
57
|
+
`File not read yet: ${displayPath}.`
|
|
58
58
|
);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const currentMtime = getFileMtime(absPath);
|
|
62
62
|
if (currentMtime > lastRead) {
|
|
63
63
|
return (
|
|
64
|
-
`
|
|
65
|
-
`
|
|
64
|
+
`Please re-read the file with the read tool before editing. ` +
|
|
65
|
+
`File modified since last read: ${displayPath}.`
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
package/extensions/io.ts
CHANGED
|
@@ -243,7 +243,13 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
|
|
|
243
243
|
return component;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
if (!body)
|
|
246
|
+
if (!body) {
|
|
247
|
+
// Patch succeeded but the diff is empty (e.g., old_str === new_str, or
|
|
248
|
+
// every hunk's reps were skipped). Tell the user nothing changed.
|
|
249
|
+
component.addChild(new Spacer(1));
|
|
250
|
+
component.addChild(new Text(theme.fg("dim", ` (no changes)`), 0, 0));
|
|
251
|
+
return component;
|
|
252
|
+
}
|
|
247
253
|
|
|
248
254
|
const lines = body.split("\n");
|
|
249
255
|
const FOLD_THRESHOLD = 45;
|
package/extensions/patch.ts
CHANGED
|
@@ -176,6 +176,103 @@ function applyOverwrite(
|
|
|
176
176
|
// Edits (exact string replacement)
|
|
177
177
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
178
178
|
|
|
179
|
+
type LocateEditResult =
|
|
180
|
+
| {
|
|
181
|
+
found: true;
|
|
182
|
+
oldNorm: string;
|
|
183
|
+
newNorm: string;
|
|
184
|
+
matchIdx: number;
|
|
185
|
+
displayAnchor?: string;
|
|
186
|
+
anchorMissing: boolean;
|
|
187
|
+
}
|
|
188
|
+
| {
|
|
189
|
+
found: false;
|
|
190
|
+
oldNorm: string;
|
|
191
|
+
anchorState: "ok" | "missing" | "not_unique";
|
|
192
|
+
anchorMessage?: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** Shared edit location logic used by both one-shot and sequential paths.
|
|
196
|
+
* Returns structured failure details when old_str is not found so callers can
|
|
197
|
+
* preserve precise diagnostics instead of guessing why matching failed.
|
|
198
|
+
* Throws ApplyError on duplicate global matches or non-unique old_str. */
|
|
199
|
+
function locateEdit(
|
|
200
|
+
edit: { old_str: string; new_str: string; anchor?: string },
|
|
201
|
+
content: string,
|
|
202
|
+
displayPath: string,
|
|
203
|
+
): LocateEditResult {
|
|
204
|
+
let oldNorm = normalizeLineEndings(edit.old_str);
|
|
205
|
+
let newNorm = normalizeLineEndings(edit.new_str);
|
|
206
|
+
|
|
207
|
+
let searchFrom = 0;
|
|
208
|
+
let displayAnchor: string | undefined;
|
|
209
|
+
let anchorMissing = false;
|
|
210
|
+
let anchorState: "ok" | "missing" | "not_unique" = "ok";
|
|
211
|
+
let anchorMessage: string | undefined;
|
|
212
|
+
|
|
213
|
+
// ── Anchor parsing ──
|
|
214
|
+
if (edit.anchor) {
|
|
215
|
+
const anchorNorm = normalizeLineEndings(edit.anchor);
|
|
216
|
+
const anchorIdx = content.indexOf(anchorNorm);
|
|
217
|
+
if (anchorIdx === -1) {
|
|
218
|
+
anchorState = "missing";
|
|
219
|
+
anchorMessage = `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
220
|
+
} else {
|
|
221
|
+
const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
|
|
222
|
+
if (secondAnchor !== -1) {
|
|
223
|
+
anchorState = "not_unique";
|
|
224
|
+
anchorMessage = `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
225
|
+
} else {
|
|
226
|
+
searchFrom = Math.max(0, anchorIdx - (oldNorm.length - 1));
|
|
227
|
+
displayAnchor = edit.anchor;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Exact match in search range ──
|
|
233
|
+
let matchIdx = anchorMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
234
|
+
|
|
235
|
+
// ── Global exact match fallback (when anchor was missing/unusable) ──
|
|
236
|
+
if (matchIdx === -1 && anchorMessage) {
|
|
237
|
+
displayAnchor = edit.anchor;
|
|
238
|
+
anchorMissing = true;
|
|
239
|
+
matchIdx = content.indexOf(oldNorm, 0);
|
|
240
|
+
if (matchIdx !== -1) {
|
|
241
|
+
const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
242
|
+
if (secondGlobalMatch !== -1) {
|
|
243
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
244
|
+
throw new ApplyError(`${anchorMessage}\n${dupDiag}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Fuzzy match ──
|
|
250
|
+
if (matchIdx === -1) {
|
|
251
|
+
const searchLine = searchFrom === 0 ? 0 : content.substring(0, searchFrom).split("\n").length - 1;
|
|
252
|
+
const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
|
|
253
|
+
if (fuzzy) {
|
|
254
|
+
oldNorm = fuzzy.matched;
|
|
255
|
+
matchIdx = fuzzy.idx;
|
|
256
|
+
newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (matchIdx === -1) {
|
|
261
|
+
return { found: false, oldNorm, anchorState, anchorMessage };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Uniqueness check (skip when anchor was used as a fallback) ──
|
|
265
|
+
if (!anchorMessage) {
|
|
266
|
+
const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
267
|
+
if (secondMatch !== -1) {
|
|
268
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
269
|
+
throw new ApplyError(`${dupDiag}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { found: true, oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing };
|
|
274
|
+
}
|
|
275
|
+
|
|
179
276
|
async function applyEdits(
|
|
180
277
|
absPath: string,
|
|
181
278
|
displayPath: string,
|
|
@@ -192,150 +289,205 @@ async function applyEdits(
|
|
|
192
289
|
|
|
193
290
|
const rawContent = fs.readFileSync(absPath, "utf8");
|
|
194
291
|
const lineEnding = detectLineEnding(rawContent);
|
|
195
|
-
|
|
292
|
+
const originalContent = normalizeLineEndings(rawContent);
|
|
196
293
|
|
|
197
|
-
// Precompute line offsets for
|
|
198
|
-
const lineOffsets = buildLineOffsets(
|
|
294
|
+
// Precompute line offsets for the original file (used throughout)
|
|
295
|
+
const lineOffsets = buildLineOffsets(originalContent);
|
|
199
296
|
const totalLines = lineOffsets.length - 1;
|
|
200
297
|
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
298
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
299
|
+
// Phase 1: try matching every old_str against the ORIGINAL snapshot.
|
|
300
|
+
// If any edit requires content from a prior edit (chained dependency),
|
|
301
|
+
// fall back to sequential mode.
|
|
302
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
303
|
+
|
|
304
|
+
const planned: Array<Extract<LocateEditResult, { found: true }>> = [];
|
|
305
|
+
let needsSequential = false;
|
|
205
306
|
|
|
206
307
|
for (const edit of edits) {
|
|
207
308
|
if (!edit.old_str) {
|
|
208
309
|
throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
|
|
209
310
|
}
|
|
210
311
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
let anchorNotFoundMessage: string | undefined;
|
|
312
|
+
const located = locateEdit(edit, originalContent, displayPath);
|
|
313
|
+
if (!located.found) {
|
|
314
|
+
// old_str not found in the original snapshot — likely chained edit.
|
|
315
|
+
// Fall back to sequential mode.
|
|
316
|
+
needsSequential = true;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
219
319
|
|
|
220
|
-
|
|
221
|
-
|
|
320
|
+
planned.push(located);
|
|
321
|
+
}
|
|
222
322
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
anchorNotFoundMessage = `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
227
|
-
} else {
|
|
228
|
-
const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
|
|
229
|
-
if (secondAnchor !== -1) {
|
|
230
|
-
anchorNotFoundMessage = `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
231
|
-
} else {
|
|
232
|
-
searchFrom = Math.max(0, anchorIdx - (oldNorm.length - 1));
|
|
233
|
-
displayAnchor = edit.anchor;
|
|
234
|
-
anchorMissing = false;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
323
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
324
|
+
// Sequential fallback — old behaviour for chained edits
|
|
325
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
238
326
|
|
|
239
|
-
|
|
240
|
-
let
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
327
|
+
if (needsSequential) {
|
|
328
|
+
let content = originalContent;
|
|
329
|
+
let cumulativeOffset = 0;
|
|
330
|
+
const rawReplacements: ReplacementInfo[] = [];
|
|
331
|
+
|
|
332
|
+
for (const edit of edits) {
|
|
333
|
+
const located = locateEdit(edit, content, displayPath);
|
|
334
|
+
if (!located.found) {
|
|
335
|
+
const diag = diagnoseOldStrMismatch(located.oldNorm, content);
|
|
336
|
+
if (located.anchorState === "missing" || located.anchorState === "not_unique") {
|
|
337
|
+
throw new ApplyError(
|
|
338
|
+
`${located.anchorMessage}\nold_str not found in ${displayPath}: "${truncate(edit.old_str)}".\n${diag}`
|
|
339
|
+
);
|
|
251
340
|
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (matchIdx === -1) {
|
|
256
|
-
// Fuzzy match fallback: normalize tab↔space + trailing whitespace
|
|
257
|
-
const searchLine = searchFrom === 0 ? 0 : content.substring(0, searchFrom).split("\n").length - 1;
|
|
258
|
-
const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
|
|
259
|
-
if (fuzzy) {
|
|
260
|
-
oldNorm = fuzzy.matched;
|
|
261
|
-
matchIdx = fuzzy.idx;
|
|
262
|
-
newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
|
|
263
|
-
} else if (anchorNotFoundMessage) {
|
|
264
|
-
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
265
|
-
throw new ApplyError(
|
|
266
|
-
`${anchorNotFoundMessage}\nold_str not found in ${displayPath}: "${truncate(edit.old_str)}".\n${diag}`
|
|
267
|
-
);
|
|
268
|
-
} else {
|
|
269
|
-
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
270
341
|
throw new ApplyError(
|
|
271
342
|
`old_str not found in ${displayPath}` +
|
|
272
343
|
(edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
|
|
273
344
|
`: "${truncate(edit.old_str)}".\n${diag}`
|
|
274
345
|
);
|
|
275
346
|
}
|
|
347
|
+
|
|
348
|
+
const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing } = located;
|
|
349
|
+
|
|
350
|
+
const oldStartLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset);
|
|
351
|
+
const oldEndLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset + oldNorm.length - 1);
|
|
352
|
+
|
|
353
|
+
content =
|
|
354
|
+
content.substring(0, matchIdx) +
|
|
355
|
+
newNorm +
|
|
356
|
+
content.substring(matchIdx + oldNorm.length);
|
|
357
|
+
|
|
358
|
+
cumulativeOffset += newNorm.length - oldNorm.length;
|
|
359
|
+
|
|
360
|
+
rawReplacements.push({
|
|
361
|
+
oldStartLine,
|
|
362
|
+
oldEndLine,
|
|
363
|
+
newStartLine: 0, // placeholder — recalculated after collapse
|
|
364
|
+
newEndLine: 0,
|
|
365
|
+
oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
366
|
+
newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
367
|
+
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
368
|
+
anchorMissing,
|
|
369
|
+
});
|
|
276
370
|
}
|
|
277
371
|
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
372
|
+
// Collapse chained-edit replacements into net-change replacements,
|
|
373
|
+
// so the TUI diff shows only the net effect (original→final).
|
|
374
|
+
const cleanReplacements = collapseSequentialReplacements(rawReplacements);
|
|
375
|
+
|
|
376
|
+
const mergedRanges = mergeRanges(cleanReplacements.map(r => ({
|
|
377
|
+
startLine: Math.max(1, r.oldStartLine - CONTEXT_LINES),
|
|
378
|
+
endLine: Math.min(totalLines, r.oldEndLine + CONTEXT_LINES),
|
|
379
|
+
})));
|
|
380
|
+
const neededLines: Map<number, string> = new Map();
|
|
381
|
+
for (const range of mergedRanges) {
|
|
382
|
+
const lines = extractLineRange(originalContent, lineOffsets, range.startLine, range.endLine);
|
|
383
|
+
for (let i = 0; i < lines.length; i++) {
|
|
384
|
+
neededLines.set(range.startLine + i, lines[i]);
|
|
286
385
|
}
|
|
287
386
|
}
|
|
288
387
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
388
|
+
const fileDiff = generateLocalDiff(displayPath, cleanReplacements, neededLines, totalLines);
|
|
389
|
+
if (result.diff) {
|
|
390
|
+
result.diff += "\n" + fileDiff;
|
|
391
|
+
} else {
|
|
392
|
+
result.diff = fileDiff;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const finalContent = restoreLineEndings(content, lineEnding);
|
|
396
|
+
if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
|
|
397
|
+
result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
fs.writeFileSync(absPath, finalContent, "utf8");
|
|
401
|
+
result.modified.push(displayPath);
|
|
402
|
+
result.replacements.set(displayPath, cleanReplacements);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
407
|
+
// Phase 2: Conflict detection — sort by position, check for overlaps
|
|
408
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
409
|
+
|
|
410
|
+
const sorted = [...planned].sort((a, b) => a.matchIdx - b.matchIdx);
|
|
411
|
+
|
|
412
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
413
|
+
const cur = sorted[i]!;
|
|
414
|
+
const next = sorted[i + 1]!;
|
|
415
|
+
const curEnd = cur.matchIdx + cur.oldNorm.length;
|
|
416
|
+
if (curEnd > next.matchIdx) {
|
|
417
|
+
const curStartLine = lineAtOffset(lineOffsets, cur.matchIdx);
|
|
418
|
+
const curEndLine = lineAtOffset(lineOffsets, curEnd - 1);
|
|
419
|
+
const nextStartLine = lineAtOffset(lineOffsets, next.matchIdx);
|
|
420
|
+
const overlapEnd = Math.min(curEnd, next.matchIdx + next.oldNorm.length);
|
|
421
|
+
const overlapEndLine = lineAtOffset(lineOffsets, overlapEnd - 1);
|
|
422
|
+
throw new ApplyError(
|
|
423
|
+
`Edits target overlapping regions in ${displayPath}: ` +
|
|
424
|
+
`edit targeting lines ${curStartLine}-${curEndLine} overlaps with ` +
|
|
425
|
+
`edit targeting lines ${nextStartLine}-${overlapEndLine}. ` +
|
|
426
|
+
`Split overlapping edits into separate patch calls.`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
432
|
+
// Phase 3: One-shot assembly — splice replacements into final content
|
|
433
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
294
434
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
435
|
+
let content = "";
|
|
436
|
+
let cursor = 0;
|
|
437
|
+
const replacements: ReplacementInfo[] = [];
|
|
438
|
+
const neededRanges: LineRange[] = [];
|
|
439
|
+
|
|
440
|
+
for (const p of sorted) {
|
|
441
|
+
// Copy original content up to this edit
|
|
442
|
+
content += originalContent.substring(cursor, p.matchIdx);
|
|
300
443
|
|
|
301
|
-
//
|
|
302
|
-
|
|
444
|
+
// Record where new_str lands in the assembled content
|
|
445
|
+
const newStartIdx = content.length;
|
|
446
|
+
content += p.newNorm;
|
|
447
|
+
const newEndIdx = content.length - 1;
|
|
303
448
|
|
|
304
|
-
// Compute
|
|
305
|
-
const
|
|
306
|
-
const
|
|
449
|
+
// Compute line numbers (original file coordinates for old, result for new)
|
|
450
|
+
const oldStartLine = lineAtOffset(lineOffsets, p.matchIdx);
|
|
451
|
+
const oldEndLine = lineAtOffset(lineOffsets, p.matchIdx + p.oldNorm.length - 1);
|
|
452
|
+
const newStartLine = charOffsetToLine(content, newStartIdx);
|
|
453
|
+
const newEndLine = charOffsetToLine(content, newEndIdx);
|
|
307
454
|
|
|
308
|
-
// Record replacement info
|
|
309
455
|
replacements.push({
|
|
310
456
|
oldStartLine,
|
|
311
457
|
oldEndLine,
|
|
312
458
|
newStartLine,
|
|
313
459
|
newEndLine,
|
|
314
|
-
oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
315
|
-
newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
316
|
-
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
317
|
-
anchorMissing,
|
|
460
|
+
oldLines: p.oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
461
|
+
newLines: p.newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
462
|
+
anchor: p.displayAnchor ? p.displayAnchor.split("\n")[0] : undefined,
|
|
463
|
+
anchorMissing: p.anchorMissing,
|
|
318
464
|
});
|
|
319
465
|
|
|
320
|
-
// Collect context range for this edit
|
|
321
466
|
neededRanges.push({
|
|
322
467
|
startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
|
|
323
468
|
endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
|
|
324
469
|
});
|
|
470
|
+
|
|
471
|
+
cursor = p.matchIdx + p.oldNorm.length;
|
|
325
472
|
}
|
|
326
473
|
|
|
327
|
-
//
|
|
474
|
+
// Copy trailing original content
|
|
475
|
+
content += originalContent.substring(cursor);
|
|
476
|
+
|
|
477
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
478
|
+
// Diff generation
|
|
479
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
480
|
+
|
|
328
481
|
const mergedRanges = mergeRanges(neededRanges);
|
|
329
|
-
const
|
|
482
|
+
const originalLineOffsets = buildLineOffsets(originalContent);
|
|
330
483
|
const neededLines: Map<number, string> = new Map();
|
|
331
484
|
for (const range of mergedRanges) {
|
|
332
|
-
const lines = extractLineRange(
|
|
485
|
+
const lines = extractLineRange(originalContent, originalLineOffsets, range.startLine, range.endLine);
|
|
333
486
|
for (let i = 0; i < lines.length; i++) {
|
|
334
487
|
neededLines.set(range.startLine + i, lines[i]);
|
|
335
488
|
}
|
|
336
489
|
}
|
|
337
490
|
|
|
338
|
-
// Build diff for this file and append to result
|
|
339
491
|
const fileDiff = generateLocalDiff(displayPath, replacements, neededLines, totalLines);
|
|
340
492
|
if (result.diff) {
|
|
341
493
|
result.diff += "\n" + fileDiff;
|
|
@@ -346,7 +498,6 @@ async function applyEdits(
|
|
|
346
498
|
// Restore line endings
|
|
347
499
|
const finalContent = restoreLineEndings(content, lineEnding);
|
|
348
500
|
|
|
349
|
-
// Warn if line endings were normalized (CRLF → LF)
|
|
350
501
|
if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
|
|
351
502
|
result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
|
|
352
503
|
}
|
|
@@ -394,6 +545,11 @@ export async function computePatchPreview(
|
|
|
394
545
|
const lineOffsets = buildLineOffsets(rawContent);
|
|
395
546
|
const totalLines = lineOffsets.length - 1;
|
|
396
547
|
let content = normalizeLineEndings(rawContent);
|
|
548
|
+
// Snapshot the original (pre-edit) content for diff display. The
|
|
549
|
+
// `content` variable below is mutated in-place as edits are applied,
|
|
550
|
+
// but the TUI diff should show the ORIGINAL lines (not the post-edit
|
|
551
|
+
// content) so leading whitespace is preserved correctly.
|
|
552
|
+
const originalContent = content;
|
|
397
553
|
const allReplacements: ReplacementInfo[] = [];
|
|
398
554
|
const neededRanges: LineRange[] = [];
|
|
399
555
|
let cumulativeOffset = 0;
|
|
@@ -471,12 +627,13 @@ export async function computePatchPreview(
|
|
|
471
627
|
cumulativeOffset += newNorm.length - oldNorm.length;
|
|
472
628
|
}
|
|
473
629
|
|
|
474
|
-
// Merge needed ranges and extract
|
|
630
|
+
// Merge needed ranges and extract lines from the ORIGINAL content
|
|
631
|
+
// (not the mutated `content`), so the diff shows pre-edit lines.
|
|
475
632
|
const mergedRanges = mergeRanges(neededRanges);
|
|
476
|
-
const
|
|
633
|
+
const originalLineOffsets = buildLineOffsets(originalContent);
|
|
477
634
|
const neededLines: Map<number, string> = new Map();
|
|
478
635
|
for (const range of mergedRanges) {
|
|
479
|
-
const lines = extractLineRange(
|
|
636
|
+
const lines = extractLineRange(originalContent, originalLineOffsets, range.startLine, range.endLine);
|
|
480
637
|
for (let i = 0; i < lines.length; i++) {
|
|
481
638
|
neededLines.set(range.startLine + i, lines[i]);
|
|
482
639
|
}
|
|
@@ -560,14 +717,17 @@ interface ChunkAnchor {
|
|
|
560
717
|
function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
|
|
561
718
|
const byText = new Map<string, ChunkAnchor>();
|
|
562
719
|
for (const rep of chunk.reps) {
|
|
563
|
-
const
|
|
564
|
-
if (!
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
720
|
+
const raw = rep.anchor?.trim();
|
|
721
|
+
if (!raw) continue;
|
|
722
|
+
// Support \n-separated anchors from collapsed sequential replacements
|
|
723
|
+
const texts = raw.includes("\n") ? raw.split("\n").map(s => s.trim()).filter(Boolean) : [raw];
|
|
724
|
+
for (const text of texts) {
|
|
725
|
+
const existing = byText.get(text);
|
|
726
|
+
if (!existing) {
|
|
727
|
+
byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
|
|
728
|
+
} else if (!rep.anchorMissing) {
|
|
729
|
+
existing.missing = false;
|
|
730
|
+
}
|
|
571
731
|
}
|
|
572
732
|
}
|
|
573
733
|
return [...byText.values()];
|
|
@@ -607,6 +767,132 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
|
|
|
607
767
|
return lines;
|
|
608
768
|
}
|
|
609
769
|
|
|
770
|
+
type RenderOp =
|
|
771
|
+
| { type: "context"; line: number; text: string }
|
|
772
|
+
| { type: "removed"; line: number; text: string }
|
|
773
|
+
| { type: "added"; line: number; text: string };
|
|
774
|
+
|
|
775
|
+
interface RenderableReplacement {
|
|
776
|
+
operations: RenderOp[];
|
|
777
|
+
/** Line number of the first removed/added operation (BEFORE trimming).
|
|
778
|
+
* Used to limit how far "context before" extends. */
|
|
779
|
+
firstChangeLine: number;
|
|
780
|
+
/** Line number of the last removed/added operation (BEFORE trimming). */
|
|
781
|
+
lastChangeLine: number;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Compute a minimal line-level diff between old and new lines using LCS.
|
|
785
|
+
* Common lines become context, while only truly different lines become
|
|
786
|
+
* removed/added. The resulting context is trimmed to `contextLines` lines
|
|
787
|
+
* before the first and after the last non-context operation so the TUI hunk
|
|
788
|
+
* doesn't grow with the size of the LLM's old_str. */
|
|
789
|
+
function splitReplacementForRender(
|
|
790
|
+
rep: ReplacementInfo,
|
|
791
|
+
contextLines: number,
|
|
792
|
+
): RenderableReplacement {
|
|
793
|
+
const m = rep.oldLines.length;
|
|
794
|
+
const n = rep.newLines.length;
|
|
795
|
+
|
|
796
|
+
// DP table: dp[i][j] = LCS length of oldLines[0..i) and newLines[0..j)
|
|
797
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
|
|
798
|
+
for (let i = 1; i <= m; i++) {
|
|
799
|
+
for (let j = 1; j <= n; j++) {
|
|
800
|
+
if (rep.oldLines[i - 1] === rep.newLines[j - 1]) {
|
|
801
|
+
dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
|
802
|
+
} else {
|
|
803
|
+
dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Backtrack to produce operations in reverse order.
|
|
809
|
+
const stack: RenderOp[] = [];
|
|
810
|
+
let i = m;
|
|
811
|
+
let j = n;
|
|
812
|
+
while (i > 0 && j > 0) {
|
|
813
|
+
if (rep.oldLines[i - 1] === rep.newLines[j - 1]) {
|
|
814
|
+
stack.push({ type: "context", line: rep.oldStartLine + i - 1, text: rep.oldLines[i - 1]! });
|
|
815
|
+
i--;
|
|
816
|
+
j--;
|
|
817
|
+
} else if (dp[i - 1]![j]! > dp[i]![j - 1]!) {
|
|
818
|
+
stack.push({ type: "removed", line: rep.oldStartLine + i - 1, text: rep.oldLines[i - 1]! });
|
|
819
|
+
i--;
|
|
820
|
+
} else {
|
|
821
|
+
stack.push({ type: "added", line: rep.oldStartLine + j - 1, text: rep.newLines[j - 1]! });
|
|
822
|
+
j--;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
while (i > 0) {
|
|
826
|
+
stack.push({ type: "removed", line: rep.oldStartLine + i - 1, text: rep.oldLines[i - 1]! });
|
|
827
|
+
i--;
|
|
828
|
+
}
|
|
829
|
+
while (j > 0) {
|
|
830
|
+
stack.push({ type: "added", line: rep.oldStartLine + j - 1, text: rep.newLines[j - 1]! });
|
|
831
|
+
j--;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Reverse to get the final order.
|
|
835
|
+
const operations: RenderOp[] = [];
|
|
836
|
+
while (stack.length > 0) operations.push(stack.pop()!);
|
|
837
|
+
|
|
838
|
+
// Find first and last non-context operations (in the original order).
|
|
839
|
+
let firstChangeLine = rep.oldStartLine;
|
|
840
|
+
let lastChangeLine = rep.oldStartLine;
|
|
841
|
+
for (const op of operations) {
|
|
842
|
+
if (op.type !== "context") {
|
|
843
|
+
firstChangeLine = op.line;
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
for (let k = operations.length - 1; k >= 0; k--) {
|
|
848
|
+
if (operations[k]!.type !== "context") {
|
|
849
|
+
lastChangeLine = operations[k]!.line;
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Trim context operations that sit far from any non-context change.
|
|
855
|
+
const firstNonContextIdx = operations.findIndex(op => op.type !== "context");
|
|
856
|
+
if (firstNonContextIdx === -1) {
|
|
857
|
+
return { operations: [], firstChangeLine, lastChangeLine };
|
|
858
|
+
}
|
|
859
|
+
let lastNonContextIdx = operations.length - 1;
|
|
860
|
+
for (let k = operations.length - 1; k >= 0; k--) {
|
|
861
|
+
if (operations[k]!.type !== "context") { lastNonContextIdx = k; break; }
|
|
862
|
+
}
|
|
863
|
+
const start = Math.max(0, firstNonContextIdx - contextLines);
|
|
864
|
+
const end = Math.min(operations.length - 1, lastNonContextIdx + contextLines);
|
|
865
|
+
const trimmed = operations.slice(start, end + 1);
|
|
866
|
+
|
|
867
|
+
return { operations: trimmed, firstChangeLine, lastChangeLine };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Compute the actual hunk range for a chunk by looking at the rendered
|
|
871
|
+
* context (before / after) and the LCS-trimmed operations. Returns
|
|
872
|
+
* [startLine, endLine] (1-based, inclusive). */
|
|
873
|
+
function computeRenderedRange(
|
|
874
|
+
chunk: ReplacementChunk,
|
|
875
|
+
repViews: Array<{ rep: ReplacementInfo; view: RenderableReplacement; beforeStart: number; afterEnd: number }>,
|
|
876
|
+
totalLines: number,
|
|
877
|
+
contextLines: number,
|
|
878
|
+
): { startLine: number; endLine: number } {
|
|
879
|
+
let renderedStart = Infinity;
|
|
880
|
+
let renderedEnd = -Infinity;
|
|
881
|
+
for (const { rep, view, beforeStart, afterEnd } of repViews) {
|
|
882
|
+
if (view.operations.length === 0 && beforeStart >= rep.oldStartLine && afterEnd <= rep.oldEndLine) continue;
|
|
883
|
+
const opStart = view.operations[0]!.line;
|
|
884
|
+
const opEnd = view.operations[view.operations.length - 1]!.line;
|
|
885
|
+
const s = Math.min(beforeStart, opStart);
|
|
886
|
+
const e = Math.max(afterEnd, opEnd);
|
|
887
|
+
if (s < renderedStart) renderedStart = s;
|
|
888
|
+
if (e > renderedEnd) renderedEnd = e;
|
|
889
|
+
}
|
|
890
|
+
if (renderedStart === Infinity) {
|
|
891
|
+
return { startLine: chunk.startLine, endLine: chunk.endLine };
|
|
892
|
+
}
|
|
893
|
+
return { startLine: renderedStart, endLine: Math.min(totalLines, renderedEnd) };
|
|
894
|
+
}
|
|
895
|
+
|
|
610
896
|
/**
|
|
611
897
|
* Generate diff as visual chunks merged by overlapping/adjacent context windows.
|
|
612
898
|
* This keeps spacing stable when multiple nearby edits would otherwise create
|
|
@@ -614,57 +900,72 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
|
|
|
614
900
|
*/
|
|
615
901
|
function generateReplacementDiff(filePath: string, reps: ReplacementInfo[], originalLines: string[]): string {
|
|
616
902
|
const parts: string[] = [];
|
|
617
|
-
parts.push(`--- ${filePath}`);
|
|
618
|
-
parts.push(`+++ ${filePath}`);
|
|
619
903
|
|
|
620
904
|
if (reps.length === 0) {
|
|
621
|
-
|
|
622
|
-
return parts.join("\n");
|
|
905
|
+
return "";
|
|
623
906
|
}
|
|
624
907
|
|
|
625
908
|
const maxLineNum = Math.max(originalLines.length, ...reps.map(r => r.oldEndLine));
|
|
626
909
|
const numWidth = String(maxLineNum).length;
|
|
627
910
|
const CONTEXT = 3;
|
|
628
911
|
const chunks = buildReplacementChunks(reps, originalLines.length, CONTEXT);
|
|
912
|
+
let firstHunk = true;
|
|
629
913
|
|
|
630
914
|
for (let c = 0; c < chunks.length; c++) {
|
|
631
915
|
const chunk = chunks[c]!;
|
|
632
|
-
if (c > 0) parts.push("");
|
|
633
|
-
parts.push(formatChunkHeader(chunk));
|
|
634
|
-
parts.push(...formatChunkMetadataLines(chunk));
|
|
635
916
|
|
|
636
|
-
|
|
917
|
+
// Pre-compute the rendered range for this chunk so the hunk header
|
|
918
|
+
// reflects what we actually emit (not the full chunk window).
|
|
919
|
+
const repViews = chunk.reps.map(rep => {
|
|
920
|
+
const v = splitReplacementForRender(rep, CONTEXT);
|
|
921
|
+
const beforeStart = Math.max(chunk.startLine, v.firstChangeLine - CONTEXT);
|
|
922
|
+
const afterEnd = Math.min(chunk.endLine, v.lastChangeLine + CONTEXT);
|
|
923
|
+
return { rep, view: v, beforeStart, afterEnd };
|
|
924
|
+
});
|
|
637
925
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
926
|
+
// Skip hunk entirely if every rep produced no effective changes
|
|
927
|
+
// (e.g., LLM sent old_str === new_str). Rendering context-only hunks
|
|
928
|
+
// is misleading — there is nothing to show.
|
|
929
|
+
if (repViews.every(r => r.view.operations.length === 0)) continue;
|
|
930
|
+
|
|
931
|
+
const { startLine: renderedStart, endLine: renderedEnd } = computeRenderedRange(
|
|
932
|
+
chunk, repViews, originalLines.length, CONTEXT,
|
|
933
|
+
);
|
|
934
|
+
const syntheticChunk = { ...chunk, startLine: renderedStart, endLine: renderedEnd };
|
|
935
|
+
parts.push(formatChunkHeader(syntheticChunk));
|
|
936
|
+
parts.push(...formatChunkMetadataLines(syntheticChunk));
|
|
937
|
+
|
|
938
|
+
for (const { rep, view, beforeStart } of repViews) {
|
|
939
|
+
// Context before this rep: only lines within CONTEXT of the first change.
|
|
940
|
+
for (let i = beforeStart; i < rep.oldStartLine; i++) {
|
|
641
941
|
const num = String(i).padStart(numWidth, " ");
|
|
642
942
|
parts.push(` ${num} ${originalLines[i - 1]}`);
|
|
643
943
|
}
|
|
644
944
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
945
|
+
for (const op of view.operations) {
|
|
946
|
+
const num = String(op.line).padStart(numWidth, " ");
|
|
947
|
+
if (op.type === "context") parts.push(` ${num} ${op.text}`);
|
|
948
|
+
// For removed lines, use the file's actual content (not the LLM's
|
|
949
|
+
// old_str) so leading whitespace is preserved even if the LLM
|
|
950
|
+
// stripped it from the old_str.
|
|
951
|
+
else if (op.type === "removed") {
|
|
952
|
+
const fileLine = originalLines[op.line - 1] ?? op.text;
|
|
953
|
+
parts.push(`-${num} ${fileLine}`);
|
|
954
|
+
}
|
|
955
|
+
else parts.push(`+${num} ${op.text}`);
|
|
655
956
|
}
|
|
656
|
-
|
|
657
|
-
cursor = rep.oldEndLine + 1;
|
|
658
957
|
}
|
|
659
958
|
|
|
660
|
-
// Trailing context
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
959
|
+
// Trailing context after the last rep (already bounded by afterEnd).
|
|
960
|
+
const lastEntry = repViews[repViews.length - 1];
|
|
961
|
+
if (lastEntry) {
|
|
962
|
+
for (let i = lastEntry.rep.oldEndLine + 1; i <= lastEntry.afterEnd; i++) {
|
|
963
|
+
const num = String(i).padStart(numWidth, " ");
|
|
964
|
+
parts.push(` ${num} ${originalLines[i - 1]}`);
|
|
965
|
+
}
|
|
664
966
|
}
|
|
665
967
|
}
|
|
666
968
|
|
|
667
|
-
if (parts[parts.length - 1] !== "") parts.push("");
|
|
668
969
|
return parts.join("\n");
|
|
669
970
|
}
|
|
670
971
|
|
|
@@ -795,6 +1096,76 @@ function charOffsetToLine(content: string, offset: number): number {
|
|
|
795
1096
|
/**
|
|
796
1097
|
* Generate diff using only the needed lines (partial file context).
|
|
797
1098
|
*/
|
|
1099
|
+
/** Collapse chained-edit replacements (where out[i] === in[i+1]) into
|
|
1100
|
+
* net-change replacements showing only the net effect (original→final). */
|
|
1101
|
+
function collapseSequentialReplacements(
|
|
1102
|
+
reps: ReplacementInfo[],
|
|
1103
|
+
): ReplacementInfo[] {
|
|
1104
|
+
const collapsed: ReplacementInfo[] = [];
|
|
1105
|
+
let i = 0;
|
|
1106
|
+
while (i < reps.length) {
|
|
1107
|
+
const start = reps[i]!;
|
|
1108
|
+
let merged: ReplacementInfo = {
|
|
1109
|
+
...start,
|
|
1110
|
+
newStartLine: start.oldStartLine,
|
|
1111
|
+
newEndLine: start.oldStartLine + start.newLines.length - 1,
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
const anchors: string[] = [];
|
|
1115
|
+
const seenAnchors = new Set<string>();
|
|
1116
|
+
const addAnchor = (raw?: string) => {
|
|
1117
|
+
if (!raw) return;
|
|
1118
|
+
for (const text of raw.split("\n").map(s => s.trim()).filter(Boolean)) {
|
|
1119
|
+
if (seenAnchors.has(text)) continue;
|
|
1120
|
+
seenAnchors.add(text);
|
|
1121
|
+
anchors.push(text);
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
addAnchor(start.anchor);
|
|
1125
|
+
|
|
1126
|
+
let j = i + 1;
|
|
1127
|
+
while (j < reps.length) {
|
|
1128
|
+
const next = reps[j]!;
|
|
1129
|
+
// Merge chained edits when next edit's input matches merged output.
|
|
1130
|
+
// We allow slightly shifted line numbers because sequential edits can
|
|
1131
|
+
// change string lengths before we compute displayed line ranges.
|
|
1132
|
+
if (!(linesEqual(merged.newLines, next.oldLines) && next.oldStartLine <= merged.oldEndLine + 1)) {
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
addAnchor(next.anchor);
|
|
1136
|
+
merged = {
|
|
1137
|
+
// Keep the ORIGINAL region from the first replacement in the chain.
|
|
1138
|
+
// Later chained replacements may have shifted line numbers, but the
|
|
1139
|
+
// net diff should still point at the original file region.
|
|
1140
|
+
oldStartLine: merged.oldStartLine,
|
|
1141
|
+
oldEndLine: merged.oldEndLine,
|
|
1142
|
+
newStartLine: merged.oldStartLine,
|
|
1143
|
+
newEndLine: merged.oldStartLine + next.newLines.length - 1,
|
|
1144
|
+
oldLines: merged.oldLines,
|
|
1145
|
+
newLines: next.newLines,
|
|
1146
|
+
anchor: undefined,
|
|
1147
|
+
anchorMissing: merged.anchorMissing || next.anchorMissing,
|
|
1148
|
+
};
|
|
1149
|
+
j++;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
collapsed.push({
|
|
1153
|
+
...merged,
|
|
1154
|
+
anchor: anchors.length > 0 ? anchors.join("\n") : undefined,
|
|
1155
|
+
});
|
|
1156
|
+
i = j;
|
|
1157
|
+
}
|
|
1158
|
+
return collapsed;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function linesEqual(a: string[], b: string[]): boolean {
|
|
1162
|
+
if (a.length !== b.length) return false;
|
|
1163
|
+
for (let i = 0; i < a.length; i++) {
|
|
1164
|
+
if (a[i] !== b[i]) return false;
|
|
1165
|
+
}
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
798
1169
|
function generateLocalDiff(
|
|
799
1170
|
filePath: string,
|
|
800
1171
|
reps: ReplacementInfo[],
|
|
@@ -804,8 +1175,7 @@ function generateLocalDiff(
|
|
|
804
1175
|
if (reps.length === 0) return "";
|
|
805
1176
|
|
|
806
1177
|
const parts: string[] = [];
|
|
807
|
-
|
|
808
|
-
parts.push(`+++ ${filePath}`);
|
|
1178
|
+
let firstHunk = true;
|
|
809
1179
|
|
|
810
1180
|
// Calculate dynamic width based on max line number
|
|
811
1181
|
const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
|
|
@@ -814,36 +1184,69 @@ function generateLocalDiff(
|
|
|
814
1184
|
// Merge replacement chunks
|
|
815
1185
|
const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
|
|
816
1186
|
for (let c = 0; c < chunks.length; c++) {
|
|
817
|
-
if (c > 0) parts.push("");
|
|
818
1187
|
const chunk = chunks[c]!;
|
|
819
|
-
|
|
820
|
-
|
|
1188
|
+
|
|
1189
|
+
// Pre-compute the rendered range so the hunk header reflects what we
|
|
1190
|
+
// actually emit (not the full chunk window).
|
|
1191
|
+
const repViews = chunk.reps.map(rep => {
|
|
1192
|
+
const view = splitReplacementForRender(rep, CONTEXT_LINES);
|
|
1193
|
+
const beforeStart = Math.max(chunk.startLine, view.firstChangeLine - CONTEXT_LINES);
|
|
1194
|
+
const afterEnd = Math.min(chunk.endLine, view.lastChangeLine + CONTEXT_LINES);
|
|
1195
|
+
return { rep, view, beforeStart, afterEnd };
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Skip hunk entirely if every rep produced no effective changes
|
|
1199
|
+
// (e.g., LLM sent old_str === new_str). Rendering context-only hunks
|
|
1200
|
+
// is misleading — there is nothing to show.
|
|
1201
|
+
if (repViews.every(r => r.view.operations.length === 0)) continue;
|
|
1202
|
+
|
|
1203
|
+
if (firstHunk) {
|
|
1204
|
+
parts.push(`--- ${filePath}`);
|
|
1205
|
+
parts.push(`+++ ${filePath}`);
|
|
1206
|
+
firstHunk = false;
|
|
1207
|
+
} else {
|
|
1208
|
+
parts.push("");
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const { startLine: renderedStart, endLine: renderedEnd } = computeRenderedRange(
|
|
1212
|
+
chunk, repViews, totalLines, CONTEXT_LINES,
|
|
1213
|
+
);
|
|
1214
|
+
const syntheticChunk = { ...chunk, startLine: renderedStart, endLine: renderedEnd };
|
|
1215
|
+
parts.push(formatChunkHeader(syntheticChunk));
|
|
1216
|
+
parts.push(...formatChunkMetadataLines(syntheticChunk));
|
|
821
1217
|
|
|
822
1218
|
// Output context + removed + added
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
for (let i = cursor; i < rep.oldStartLine; i++) {
|
|
1219
|
+
for (const { rep, view, beforeStart } of repViews) {
|
|
1220
|
+
// Context before this replacement (only lines close to the first change)
|
|
1221
|
+
for (let i = beforeStart; i < rep.oldStartLine; i++) {
|
|
827
1222
|
const lineText = neededLines.get(i);
|
|
828
1223
|
if (lineText !== undefined) {
|
|
829
1224
|
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
830
1225
|
}
|
|
831
1226
|
}
|
|
832
|
-
|
|
833
|
-
for (
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1227
|
+
|
|
1228
|
+
for (const op of view.operations) {
|
|
1229
|
+
const num = String(op.line).padStart(numWidth, " ");
|
|
1230
|
+
if (op.type === "context") parts.push(` ${num} ${op.text}`);
|
|
1231
|
+
// For removed lines, use the file's actual content (not the LLM's
|
|
1232
|
+
// old_str) so leading whitespace is preserved even if the LLM
|
|
1233
|
+
// stripped it from the old_str.
|
|
1234
|
+
else if (op.type === "removed") {
|
|
1235
|
+
const fileLine = neededLines.get(op.line) ?? op.text;
|
|
1236
|
+
parts.push(`-${num} ${fileLine}`);
|
|
1237
|
+
}
|
|
1238
|
+
else parts.push(`+${num} ${op.text}`);
|
|
839
1239
|
}
|
|
840
|
-
cursor = rep.oldEndLine + 1;
|
|
841
1240
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1241
|
+
|
|
1242
|
+
// Trailing context after the last rep (already bounded by afterEnd).
|
|
1243
|
+
const lastEntry = repViews[repViews.length - 1];
|
|
1244
|
+
if (lastEntry) {
|
|
1245
|
+
for (let i = lastEntry.rep.oldEndLine + 1; i <= lastEntry.afterEnd; i++) {
|
|
1246
|
+
const lineText = neededLines.get(i);
|
|
1247
|
+
if (lineText !== undefined) {
|
|
1248
|
+
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
1249
|
+
}
|
|
847
1250
|
}
|
|
848
1251
|
}
|
|
849
1252
|
}
|
|
@@ -9,25 +9,50 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/** Maximum length of the auto-derived session title. Single-line TUI footers
|
|
13
|
+
* break easily, so we cap in addition to newline truncation. */
|
|
14
|
+
export const MAX_SESSION_TITLE_LENGTH = 80;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
interface SessionEntryLike {
|
|
17
|
+
type: string;
|
|
18
|
+
message?: { role: string; content?: unknown };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pickFirstUserText(content: unknown): string | undefined {
|
|
22
|
+
if (typeof content === "string" && content.trim()) {
|
|
23
|
+
return content.trim();
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(content)) {
|
|
26
|
+
for (const part of content) {
|
|
27
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
28
|
+
return part.text.trim();
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
return undefined;
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/** Extract a single-line title from the first user message.
|
|
36
|
+
* Truncates at the first newline and caps length to keep the TUI footer safe. */
|
|
37
|
+
export function extractFirstMessage(entries: SessionEntryLike[]): string | undefined {
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.type !== "message" || !entry.message || entry.message.role !== "user") continue;
|
|
40
|
+
|
|
41
|
+
const raw = pickFirstUserText(entry.message.content);
|
|
42
|
+
if (!raw) continue;
|
|
43
|
+
|
|
44
|
+
// Truncate at first newline to avoid breaking TUI footer layout.
|
|
45
|
+
const nl = raw.indexOf("\n");
|
|
46
|
+
const oneLine = (nl === -1 ? raw : raw.slice(0, nl)).trim();
|
|
47
|
+
if (!oneLine) continue;
|
|
48
|
+
|
|
49
|
+
// Cap length to keep footer/terminal title rows short.
|
|
50
|
+
if (oneLine.length <= MAX_SESSION_TITLE_LENGTH) return oneLine;
|
|
51
|
+
return oneLine.slice(0, MAX_SESSION_TITLE_LENGTH - 1) + "…";
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
export function setupSessionTitle(pi: ExtensionAPI) {
|
|
32
57
|
pi.on("session_start", (_event, ctx) => {
|
|
33
58
|
if (ctx.sessionManager.getSessionName()) return;
|