decorated-pi 0.5.4 → 0.6.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.
Files changed (68) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/hooks/session-title.ts +54 -0
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
  43. package/{extensions/io.ts → tools/patch/index.ts} +29 -205
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/session-title.ts +0 -40
  59. package/extensions/slash.ts +0 -458
  60. package/extensions/smart-at.ts +0 -481
  61. package/extensions/subdir-agents.ts +0 -151
  62. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  64. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  65. /package/{extensions → tools}/lsp/env.ts +0 -0
  66. /package/{extensions → tools}/lsp/manager.ts +0 -0
  67. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  68. /package/{extensions → tools}/lsp/protocol.ts +0 -0
@@ -545,6 +545,11 @@ export async function computePatchPreview(
545
545
  const lineOffsets = buildLineOffsets(rawContent);
546
546
  const totalLines = lineOffsets.length - 1;
547
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;
548
553
  const allReplacements: ReplacementInfo[] = [];
549
554
  const neededRanges: LineRange[] = [];
550
555
  let cumulativeOffset = 0;
@@ -622,12 +627,13 @@ export async function computePatchPreview(
622
627
  cumulativeOffset += newNorm.length - oldNorm.length;
623
628
  }
624
629
 
625
- // Merge needed ranges and extract only those lines
630
+ // Merge needed ranges and extract lines from the ORIGINAL content
631
+ // (not the mutated `content`), so the diff shows pre-edit lines.
626
632
  const mergedRanges = mergeRanges(neededRanges);
627
- const currentLineOffsets = buildLineOffsets(content);
633
+ const originalLineOffsets = buildLineOffsets(originalContent);
628
634
  const neededLines: Map<number, string> = new Map();
629
635
  for (const range of mergedRanges) {
630
- const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
636
+ const lines = extractLineRange(originalContent, originalLineOffsets, range.startLine, range.endLine);
631
637
  for (let i = 0; i < lines.length; i++) {
632
638
  neededLines.set(range.startLine + i, lines[i]);
633
639
  }
@@ -761,6 +767,160 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
761
767
  return lines;
762
768
  }
763
769
 
770
+ type RenderOp =
771
+ | { type: "context"; line: number; text: string; /** New-file line number (differs from `line` when there are added/removed lines before it). Optional for backward compat. */ newLine?: number }
772
+ | { type: "removed"; line: number; text: string; /** Optional for type compatibility — removed lines always keep their original `line` and never receive a `newLine` assignment. */ newLine?: number }
773
+ | { type: "added"; line: number; text: string; /** New-file line number (differs from `line` when there are added/removed lines before it). Optional for backward compat. */ newLine?: number };
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
+ // Second pass: compute the new-file line number for each operation.
868
+ // Standard unified-diff convention: context/removed use ORIGINAL line
869
+ // numbers; added use NEW-file line numbers (so they don't collide with
870
+ // the line numbers of unchanged lines that follow in the file).
871
+ let newLineCounter = rep.oldStartLine;
872
+ for (const op of trimmed) {
873
+ if (op.type === "context" || op.type === "added") {
874
+ op.newLine = newLineCounter;
875
+ newLineCounter++;
876
+ }
877
+ // removed: no new-file line; skip increment
878
+ }
879
+
880
+ return { operations: trimmed, firstChangeLine, lastChangeLine };
881
+ }
882
+
883
+ /** Compute the actual hunk range for a chunk by looking at the rendered
884
+ * context (before / after) and the LCS-trimmed operations. Returns
885
+ * [startLine, endLine] (1-based, inclusive). */
886
+ function computeRenderedRange(
887
+ chunk: ReplacementChunk,
888
+ repViews: Array<{ rep: ReplacementInfo; view: RenderableReplacement; beforeStart: number; afterEnd: number }>,
889
+ totalLines: number,
890
+ contextLines: number,
891
+ ): { startLine: number; endLine: number } {
892
+ let renderedStart = Infinity;
893
+ let renderedEnd = -Infinity;
894
+ for (const { view, beforeStart, afterEnd } of repViews) {
895
+ // Skip reps with no operations (no changes to render). The original
896
+ // check also required beforeStart >= oldStartLine && afterEnd <= oldEndLine,
897
+ // but that fails when oldStartLine > CONTEXT_LINES (beforeStart would be
898
+ // oldStartLine - CONTEXT_LINES < oldStartLine), causing view.operations[0]
899
+ // to be undefined and throw "Cannot read properties of undefined".
900
+ if (view.operations.length === 0) continue;
901
+ // Use the new-file line number of the first/last operations so the
902
+ // hunk header matches the line numbers used in the diff content.
903
+ const opStart = view.operations[0]!.newLine ?? view.operations[0]!.line;
904
+ const opEnd = view.operations[view.operations.length - 1]!.newLine ?? view.operations[view.operations.length - 1]!.line;
905
+ // beforeStart / afterEnd are in original line numbers; convert via
906
+ // the offset of this rep's operations. For a clean approximation
907
+ // (and to avoid running into line-number collisions), use the rep's
908
+ // own oldStartLine/oldEndLine as the anchor for the conversion.
909
+ const origStart = view.operations[0]!.line;
910
+ const origEnd = view.operations[view.operations.length - 1]!.line;
911
+ const beforeNew = opStart - (origStart - beforeStart);
912
+ const afterNew = opEnd + (afterEnd - origEnd);
913
+ const s = Math.min(beforeNew, opStart);
914
+ const e = Math.max(afterNew, opEnd);
915
+ if (s < renderedStart) renderedStart = s;
916
+ if (e > renderedEnd) renderedEnd = e;
917
+ }
918
+ if (renderedStart === Infinity) {
919
+ return { startLine: chunk.startLine, endLine: chunk.endLine };
920
+ }
921
+ return { startLine: renderedStart, endLine: Math.min(totalLines, renderedEnd) };
922
+ }
923
+
764
924
  /**
765
925
  * Generate diff as visual chunks merged by overlapping/adjacent context windows.
766
926
  * This keeps spacing stable when multiple nearby edits would otherwise create
@@ -768,57 +928,104 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
768
928
  */
769
929
  function generateReplacementDiff(filePath: string, reps: ReplacementInfo[], originalLines: string[]): string {
770
930
  const parts: string[] = [];
771
- parts.push(`--- ${filePath}`);
772
- parts.push(`+++ ${filePath}`);
773
931
 
774
932
  if (reps.length === 0) {
775
- parts.push("");
776
- return parts.join("\n");
933
+ return "";
777
934
  }
778
935
 
779
936
  const maxLineNum = Math.max(originalLines.length, ...reps.map(r => r.oldEndLine));
780
937
  const numWidth = String(maxLineNum).length;
781
938
  const CONTEXT = 3;
782
939
  const chunks = buildReplacementChunks(reps, originalLines.length, CONTEXT);
940
+ let firstHunk = true;
783
941
 
784
942
  for (let c = 0; c < chunks.length; c++) {
785
943
  const chunk = chunks[c]!;
786
- if (c > 0) parts.push("");
787
- parts.push(formatChunkHeader(chunk));
788
- parts.push(...formatChunkMetadataLines(chunk));
789
944
 
790
- let cursor = chunk.startLine;
945
+ // Pre-compute the rendered range for this chunk so the hunk header
946
+ // reflects what we actually emit (not the full chunk window).
947
+ const repViews = chunk.reps.map(rep => {
948
+ const v = splitReplacementForRender(rep, CONTEXT);
949
+ const beforeStart = Math.max(chunk.startLine, v.firstChangeLine - CONTEXT);
950
+ const afterEnd = Math.min(chunk.endLine, v.lastChangeLine + CONTEXT);
951
+ return { rep, view: v, beforeStart, afterEnd };
952
+ });
953
+
954
+ // Skip hunk entirely if every rep produced no effective changes
955
+ // (e.g., LLM sent old_str === new_str). Rendering context-only hunks
956
+ // is misleading — there is nothing to show.
957
+ if (repViews.every(r => r.view.operations.length === 0)) continue;
791
958
 
792
- for (const rep of chunk.reps) {
793
- // Context before this replacement (only once per original line)
794
- for (let i = cursor; i < rep.oldStartLine; i++) {
959
+ const { startLine: renderedStart, endLine: renderedEnd } = computeRenderedRange(
960
+ chunk, repViews, originalLines.length, CONTEXT,
961
+ );
962
+ const syntheticChunk = { ...chunk, startLine: renderedStart, endLine: renderedEnd };
963
+ parts.push(formatChunkHeader(syntheticChunk));
964
+ parts.push(...formatChunkMetadataLines(syntheticChunk));
965
+
966
+ let lastOutputLine = 0;
967
+ for (const { rep, view, beforeStart } of repViews) {
968
+ // Skip no-op reps (old_str === new_str): no changes to show, and
969
+ // emitting their context lines would mislead the reader.
970
+ if (view.operations.length === 0) continue;
971
+
972
+ // Context before this rep. Start from `lastOutputLine + 1` to
973
+ // avoid duplicating context lines already emitted by the previous
974
+ // rep's before-context window or operations.
975
+ const ctxStart = Math.max(lastOutputLine + 1, beforeStart);
976
+ for (let i = ctxStart; i < rep.oldStartLine; i++) {
795
977
  const num = String(i).padStart(numWidth, " ");
796
978
  parts.push(` ${num} ${originalLines[i - 1]}`);
979
+ lastOutputLine = i;
797
980
  }
798
981
 
799
- // Removed lines (from original)
800
- for (let i = 0; i < rep.oldLines.length; i++) {
801
- const num = String(rep.oldStartLine + i).padStart(numWidth, " ");
802
- parts.push(`-${num} ${rep.oldLines[i]}`);
803
- }
804
-
805
- // Added lines
806
- for (let i = 0; i < rep.newLines.length; i++) {
807
- const num = String(rep.oldStartLine + i).padStart(numWidth, " ");
808
- parts.push(`+${num} ${rep.newLines[i]}`);
982
+ for (const op of view.operations) {
983
+ // Use the new-file line number for context and added lines so
984
+ // they don't conflict with the original-file line numbers used by
985
+ // the trailing-context loop. Removed lines keep the original.
986
+ const newNum = op.newLine !== undefined
987
+ ? String(op.newLine).padStart(numWidth, " ")
988
+ : String(op.line).padStart(numWidth, " ");
989
+ const origNum = String(op.line).padStart(numWidth, " ");
990
+ if (op.type === "context") {
991
+ parts.push(` ${newNum} ${op.text}`);
992
+ lastOutputLine = op.line;
993
+ }
994
+ // For removed lines, use the file's actual content (not the LLM's
995
+ // old_str) so leading whitespace is preserved even if the LLM
996
+ // stripped it from the old_str.
997
+ else if (op.type === "removed") {
998
+ const fileLine = originalLines[op.line - 1] ?? op.text;
999
+ parts.push(`-${origNum} ${fileLine}`);
1000
+ lastOutputLine = op.line;
1001
+ }
1002
+ else {
1003
+ parts.push(`+${newNum} ${op.text}`);
1004
+ }
809
1005
  }
810
-
811
- cursor = rep.oldEndLine + 1;
812
1006
  }
813
1007
 
814
- // Trailing context for the merged chunk
815
- for (let i = cursor; i <= chunk.endLine; i++) {
816
- const num = String(i).padStart(numWidth, " ");
817
- parts.push(` ${num} ${originalLines[i - 1]}`);
1008
+ // Trailing context after the LAST NON-NOOP rep. Use new-file line
1009
+ // numbers (offset from the last operation's newLine) so trailing
1010
+ // context doesn't collide with added lines.
1011
+ let lastRealEntry: typeof repViews[number] | undefined;
1012
+ for (const rv of repViews) {
1013
+ if (rv.view.operations.length > 0) lastRealEntry = rv;
1014
+ }
1015
+ if (lastRealEntry) {
1016
+ const lastOp = lastRealEntry.view.operations[lastRealEntry.view.operations.length - 1];
1017
+ const lastNewLine = lastOp?.newLine ?? lastOp?.line ?? lastRealEntry.rep.oldEndLine;
1018
+ const lastOrigLine = lastOp?.line ?? lastRealEntry.rep.oldEndLine;
1019
+ // Start from lastOutputLine + 1 to avoid duplicating context
1020
+ // already emitted.
1021
+ const ctxStart = Math.max(lastOutputLine + 1, lastRealEntry.rep.oldEndLine + 1);
1022
+ for (let i = ctxStart; i <= lastRealEntry.afterEnd; i++) {
1023
+ const num = String(lastNewLine + (i - lastOrigLine)).padStart(numWidth, " ");
1024
+ parts.push(` ${num} ${originalLines[i - 1]}`);
1025
+ }
818
1026
  }
819
1027
  }
820
1028
 
821
- if (parts[parts.length - 1] !== "") parts.push("");
822
1029
  return parts.join("\n");
823
1030
  }
824
1031
 
@@ -826,18 +1033,12 @@ function generateReplacementDiff(filePath: string, reps: ReplacementInfo[], orig
826
1033
  // Formatting
827
1034
  // ═══════════════════════════════════════════════════════════════════════════
828
1035
 
829
- export function formatPatchResult(result: PatchResult): string {
830
- const lines: string[] = [];
831
- for (const p of result.created) lines.push(`A ${p}`);
832
- for (const p of result.modified) lines.push(`M ${p}`);
833
- let output = lines.length > 0
834
- ? "Updated the following files:\n" + lines.join("\n")
835
- : "No files were modified.";
836
- if (result.warnings.length > 0) {
837
- output += "\n\n" + result.warnings.join("\n");
838
- }
839
- return output;
840
- }
1036
+ // Note: a previous `formatPatchResult` helper lived here. It was removed
1037
+ // when the tool's execute() was simplified to return a constant "Success"
1038
+ // string (the LLM already knows what it asked to change, so the summary
1039
+ // was redundant and cost prompt-cache stability). If callers need to
1040
+ // surface the file list to a non-LLM UI, they can format `result.modified`
1041
+ // and `result.created` themselves they are plain `string[]`.
841
1042
 
842
1043
  // ═══════════════════════════════════════════════════════════════════════════
843
1044
  // Helpers
@@ -1028,8 +1229,7 @@ function generateLocalDiff(
1028
1229
  if (reps.length === 0) return "";
1029
1230
 
1030
1231
  const parts: string[] = [];
1031
- parts.push(`--- ${filePath}`);
1032
- parts.push(`+++ ${filePath}`);
1232
+ let firstHunk = true;
1033
1233
 
1034
1234
  // Calculate dynamic width based on max line number
1035
1235
  const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
@@ -1038,36 +1238,107 @@ function generateLocalDiff(
1038
1238
  // Merge replacement chunks
1039
1239
  const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
1040
1240
  for (let c = 0; c < chunks.length; c++) {
1041
- if (c > 0) parts.push("");
1042
1241
  const chunk = chunks[c]!;
1043
- parts.push(formatChunkHeader(chunk));
1044
- parts.push(...formatChunkMetadataLines(chunk));
1242
+
1243
+ // Pre-compute the rendered range so the hunk header reflects what we
1244
+ // actually emit (not the full chunk window).
1245
+ const repViews = chunk.reps.map(rep => {
1246
+ const view = splitReplacementForRender(rep, CONTEXT_LINES);
1247
+ const beforeStart = Math.max(chunk.startLine, view.firstChangeLine - CONTEXT_LINES);
1248
+ const afterEnd = Math.min(chunk.endLine, view.lastChangeLine + CONTEXT_LINES);
1249
+ return { rep, view, beforeStart, afterEnd };
1250
+ });
1251
+
1252
+ // Skip hunk entirely if every rep produced no effective changes
1253
+ // (e.g., LLM sent old_str === new_str). Rendering context-only hunks
1254
+ // is misleading — there is nothing to show.
1255
+ if (repViews.every(r => r.view.operations.length === 0)) continue;
1256
+
1257
+ if (firstHunk) {
1258
+ parts.push(`--- ${filePath}`);
1259
+ parts.push(`+++ ${filePath}`);
1260
+ firstHunk = false;
1261
+ } else {
1262
+ parts.push("");
1263
+ }
1264
+
1265
+ const { startLine: renderedStart, endLine: renderedEnd } = computeRenderedRange(
1266
+ chunk, repViews, totalLines, CONTEXT_LINES,
1267
+ );
1268
+ const syntheticChunk = { ...chunk, startLine: renderedStart, endLine: renderedEnd };
1269
+ parts.push(formatChunkHeader(syntheticChunk));
1270
+ parts.push(...formatChunkMetadataLines(syntheticChunk));
1045
1271
 
1046
1272
  // Output context + removed + added
1047
- let cursor = chunk.startLine;
1048
- for (const rep of chunk.reps) {
1049
- // Context before this replacement
1050
- for (let i = cursor; i < rep.oldStartLine; i++) {
1273
+ let lastOutputLine = 0;
1274
+ for (const { rep, view, beforeStart } of repViews) {
1275
+ // Skip no-op reps (old_str === new_str): no changes to show, and
1276
+ // emitting their context lines would mislead the reader.
1277
+ if (view.operations.length === 0) continue;
1278
+
1279
+ // Context before this rep. Start from `lastOutputLine + 1` (not
1280
+ // `beforeStart`) to avoid duplicating context lines that were
1281
+ // already emitted by the previous rep's before-context window
1282
+ // or operations (common when multiple reps are close together).
1283
+ const ctxStart = Math.max(lastOutputLine + 1, beforeStart);
1284
+ for (let i = ctxStart; i < rep.oldStartLine; i++) {
1051
1285
  const lineText = neededLines.get(i);
1052
1286
  if (lineText !== undefined) {
1053
1287
  parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
1288
+ lastOutputLine = i;
1054
1289
  }
1055
1290
  }
1056
- // Removed lines
1057
- for (let i = 0; i < rep.oldLines.length; i++) {
1058
- parts.push(`-${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.oldLines[i]}`);
1059
- }
1060
- // Added lines
1061
- for (let i = 0; i < rep.newLines.length; i++) {
1062
- parts.push(`+${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.newLines[i]}`);
1291
+
1292
+ for (const op of view.operations) {
1293
+ // Use the new-file line number for context and added lines so
1294
+ // they don't conflict with the original-file line numbers used by
1295
+ // the trailing-context loop. Removed lines keep the original.
1296
+ const newNum = op.newLine !== undefined
1297
+ ? String(op.newLine).padStart(numWidth, " ")
1298
+ : String(op.line).padStart(numWidth, " ");
1299
+ const origNum = String(op.line).padStart(numWidth, " ");
1300
+ if (op.type === "context") {
1301
+ parts.push(` ${newNum} ${op.text}`);
1302
+ lastOutputLine = op.line;
1303
+ }
1304
+ // For removed lines, use the file's actual content (not the LLM's
1305
+ // old_str) so leading whitespace is preserved even if the LLM
1306
+ // stripped it from the old_str.
1307
+ else if (op.type === "removed") {
1308
+ const fileLine = neededLines.get(op.line) ?? op.text;
1309
+ parts.push(`-${origNum} ${fileLine}`);
1310
+ lastOutputLine = op.line;
1311
+ }
1312
+ else {
1313
+ parts.push(`+${newNum} ${op.text}`);
1314
+ // Don't bump lastOutputLine for added (no original line to consume)
1315
+ }
1063
1316
  }
1064
- cursor = rep.oldEndLine + 1;
1065
1317
  }
1066
- // Trailing context
1067
- for (let i = cursor; i <= chunk.endLine; i++) {
1068
- const lineText = neededLines.get(i);
1069
- if (lineText !== undefined) {
1070
- parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
1318
+
1319
+ // Trailing context after the LAST NON-NOOP rep (a no-op's trailing
1320
+ // context would be based on the no-op's line range, not the real
1321
+ // change's end, which would skip past the real change's after-context).
1322
+ // Use new-file line numbers (offset from the last operation's newLine)
1323
+ // so trailing context doesn't collide with added lines.
1324
+ let lastRealEntry: typeof repViews[number] | undefined;
1325
+ for (const rv of repViews) {
1326
+ if (rv.view.operations.length > 0) lastRealEntry = rv;
1327
+ }
1328
+ if (lastRealEntry) {
1329
+ const lastOp = lastRealEntry.view.operations[lastRealEntry.view.operations.length - 1];
1330
+ const lastNewLine = lastOp?.newLine ?? lastOp?.line ?? lastRealEntry.rep.oldEndLine;
1331
+ const lastOrigLine = lastOp?.line ?? lastRealEntry.rep.oldEndLine;
1332
+ // Start from lastOutputLine + 1 to avoid duplicating context
1333
+ // already emitted (e.g., when the rep's operations ended with a
1334
+ // context op and then we'd otherwise re-emit the same line).
1335
+ const ctxStart = Math.max(lastOutputLine + 1, lastRealEntry.rep.oldEndLine + 1);
1336
+ for (let i = ctxStart; i <= lastRealEntry.afterEnd; i++) {
1337
+ const lineText = neededLines.get(i);
1338
+ if (lineText !== undefined) {
1339
+ const newLine = lastNewLine + (i - lastOrigLine);
1340
+ parts.push(` ${String(newLine).padStart(numWidth, " ")} ${lineText}`);
1341
+ }
1071
1342
  }
1072
1343
  }
1073
1344
  }