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.
- package/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
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
|
|
1048
|
-
for (const rep of
|
|
1049
|
-
//
|
|
1050
|
-
|
|
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
|
-
|
|
1057
|
-
for (
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
}
|