decorated-pi 0.5.4 → 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.
@@ -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
- `File not read yet: ${displayPath}. ` +
57
- `Please read the file with the read tool before editing.`
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
- `File modified since last read: ${displayPath}. ` +
65
- `Please re-read the file with the read tool before editing.`
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) return component;
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;
@@ -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,132 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
761
767
  return lines;
762
768
  }
763
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
+
764
896
  /**
765
897
  * Generate diff as visual chunks merged by overlapping/adjacent context windows.
766
898
  * This keeps spacing stable when multiple nearby edits would otherwise create
@@ -768,57 +900,72 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
768
900
  */
769
901
  function generateReplacementDiff(filePath: string, reps: ReplacementInfo[], originalLines: string[]): string {
770
902
  const parts: string[] = [];
771
- parts.push(`--- ${filePath}`);
772
- parts.push(`+++ ${filePath}`);
773
903
 
774
904
  if (reps.length === 0) {
775
- parts.push("");
776
- return parts.join("\n");
905
+ return "";
777
906
  }
778
907
 
779
908
  const maxLineNum = Math.max(originalLines.length, ...reps.map(r => r.oldEndLine));
780
909
  const numWidth = String(maxLineNum).length;
781
910
  const CONTEXT = 3;
782
911
  const chunks = buildReplacementChunks(reps, originalLines.length, CONTEXT);
912
+ let firstHunk = true;
783
913
 
784
914
  for (let c = 0; c < chunks.length; c++) {
785
915
  const chunk = chunks[c]!;
786
- if (c > 0) parts.push("");
787
- parts.push(formatChunkHeader(chunk));
788
- parts.push(...formatChunkMetadataLines(chunk));
789
916
 
790
- let cursor = chunk.startLine;
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
+ });
925
+
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));
791
937
 
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++) {
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++) {
795
941
  const num = String(i).padStart(numWidth, " ");
796
942
  parts.push(` ${num} ${originalLines[i - 1]}`);
797
943
  }
798
944
 
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]}`);
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}`);
809
956
  }
810
-
811
- cursor = rep.oldEndLine + 1;
812
957
  }
813
958
 
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]}`);
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
+ }
818
966
  }
819
967
  }
820
968
 
821
- if (parts[parts.length - 1] !== "") parts.push("");
822
969
  return parts.join("\n");
823
970
  }
824
971
 
@@ -1028,8 +1175,7 @@ function generateLocalDiff(
1028
1175
  if (reps.length === 0) return "";
1029
1176
 
1030
1177
  const parts: string[] = [];
1031
- parts.push(`--- ${filePath}`);
1032
- parts.push(`+++ ${filePath}`);
1178
+ let firstHunk = true;
1033
1179
 
1034
1180
  // Calculate dynamic width based on max line number
1035
1181
  const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
@@ -1038,36 +1184,69 @@ function generateLocalDiff(
1038
1184
  // Merge replacement chunks
1039
1185
  const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
1040
1186
  for (let c = 0; c < chunks.length; c++) {
1041
- if (c > 0) parts.push("");
1042
1187
  const chunk = chunks[c]!;
1043
- parts.push(formatChunkHeader(chunk));
1044
- parts.push(...formatChunkMetadataLines(chunk));
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));
1045
1217
 
1046
1218
  // 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++) {
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++) {
1051
1222
  const lineText = neededLines.get(i);
1052
1223
  if (lineText !== undefined) {
1053
1224
  parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
1054
1225
  }
1055
1226
  }
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]}`);
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}`);
1063
1239
  }
1064
- cursor = rep.oldEndLine + 1;
1065
1240
  }
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}`);
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
+ }
1071
1250
  }
1072
1251
  }
1073
1252
  }
@@ -9,25 +9,50 @@
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
 
12
- function extractFirstMessage(entries: Array<{ type: string; message?: { role: string; content?: unknown } }>): string | undefined {
13
- for (const entry of entries) {
14
- if (entry.type !== "message" || !entry.message || entry.message.role !== "user") continue;
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
- const content = entry.message.content;
17
- if (typeof content === "string" && content.trim()) {
18
- return content.trim();
19
- }
20
- if (Array.isArray(content)) {
21
- for (const part of content) {
22
- if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.trim()) {
23
- return part.text.trim();
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "decorated-pi is a practical enhancement pack for pi — smarter tools that are token efficient and cache friendly.",
5
5
  "keywords": [
6
6
  "pi",