decorated-pi 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,12 +14,10 @@ pi install /path/to/decorated-pi
14
14
 
15
15
  ### 1. Patch Tool
16
16
 
17
- Replaces Pi's built-in `edit` / `write` with a stronger `patch` tool that adds unique safety and usability improvements on top of the native tools.
17
+ Replaces Pi's built-in `edit` with a stronger `patch` tool that adds unique safety and usability improvements on top of the native tools.
18
18
 
19
19
  | Capability | Pi native `edit` | `patch` |
20
20
  | ------ | :---: | :---: |
21
- | Exact string replacement | ✅ `oldText` | ✅ `old_str` |
22
- | Atomic overwrite | ✅ `write` | ✅ `overwrite` |
23
21
  | Syntax‑highlighted overwrite | ✅ streaming | ✅ incremental |
24
22
  | **Anchor‑based search** | ❌ extending `oldText` for uniqueness | ✅ `anchor` bounds scope for precise matching |
25
23
  | **Fuzzy whitespace match** | ❌ only reports "not found" | ✅ auto‑corrects tab↔space / trailing whitespace mismatches |
@@ -4,8 +4,6 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
 
7
- // Extend prompt cache TTL for all providers: Anthropic 1h, OpenAI 24h
8
- process.env.PI_CACHE_RETENTION ??= "long";
9
7
  import { setupSafety } from "./safety/index.js";
10
8
  import { setupModelIntegration } from "./model-integration";
11
9
  import { setupSlash } from "./slash";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared tool-output externalization helpers.
3
+ *
4
+ * Used by `setupIO` (for read / bash) and `maybeExternalizeMcpResult` (for MCP tools).
5
+ * Each writes to the same temp dir; MCP also adds a 2KB preview, while read / bash
6
+ * emit a single-line placeholder.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import { randomBytes } from "node:crypto";
12
+
13
+ export const TOOL_OUTPUT_TEMP_DIR = path.join(os.tmpdir(), "decorated-pi-results");
14
+
15
+ /** Write content to a temp file under TOOL_OUTPUT_TEMP_DIR.
16
+ * Returns the file path, or undefined on failure (e.g., /tmp full). */
17
+ export function writeOutputToTemp(
18
+ toolName: string,
19
+ toolCallId: string,
20
+ content: string,
21
+ ): string | undefined {
22
+ try {
23
+ if (!fs.existsSync(TOOL_OUTPUT_TEMP_DIR)) {
24
+ fs.mkdirSync(TOOL_OUTPUT_TEMP_DIR, { recursive: true });
25
+ }
26
+ const id = toolCallId ? toolCallId.slice(0, 12) : randomBytes(8).toString("hex");
27
+ const filePath = path.join(TOOL_OUTPUT_TEMP_DIR, `${toolName}-${id}.txt`);
28
+ fs.writeFileSync(filePath, content, "utf-8");
29
+ return filePath;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
package/extensions/io.ts CHANGED
@@ -26,9 +26,10 @@
26
26
  * 6. prepareArguments must handle literal newlines in JSON strings
27
27
  */
28
28
 
29
- import { defineTool, isReadToolResult, keyHint, getLanguageFromPath, highlightCode, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import { defineTool, isEditToolResult, isReadToolResult, isWriteToolResult, keyHint, type ExtensionAPI, type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
30
30
  import { renderDiff } from "@earendil-works/pi-coding-agent";
31
31
  import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
32
+ import { writeOutputToTemp } from "./io-tool-output.js";
32
33
  import { Type } from "typebox";
33
34
  import {
34
35
  applyPatch,
@@ -64,15 +65,9 @@ const PatchSchema = Type.Object({
64
65
  path: Type.String({
65
66
  description: "Path to the file to edit (relative or absolute).",
66
67
  }),
67
- edits: Type.Optional(Type.Array(EditSchema, {
68
- description: "Targeted replacements applied sequentially.",
69
- })),
70
- overwrite: Type.Optional(Type.Boolean({
71
- description: "If true, replace the entire file atomically (write temp → mv).",
72
- })),
73
- new_str: Type.Optional(Type.String({
74
- description: "Entire new file content when overwrite is true.",
75
- })),
68
+ edits: Type.Array(EditSchema, {
69
+ description: "Targeted replacements applied sequentially. Each edit does exact string replacement with optional anchor.",
70
+ }),
76
71
  });
77
72
 
78
73
  // ─── Argument repair ───────────────────────────────────────────────────────────────
@@ -105,26 +100,6 @@ export function preparePatchArguments(input: any): any {
105
100
 
106
101
  const args = input as Record<string, any>;
107
102
 
108
- // Legacy multi-file format: { patches: [{ path, edits }] } → extract first
109
- if (Array.isArray(args.patches) && !args.path) {
110
- const first = args.patches[0];
111
- if (first && typeof first === "object" && first.path) {
112
- Object.assign(args, first);
113
- delete args.patches;
114
- }
115
- } else if (typeof args.patches === "string" && !args.path) {
116
- try {
117
- const parsed = jsonParseWithNewlineFix(args.patches);
118
- if (Array.isArray(parsed) && parsed.length > 0 && parsed[0]?.path) {
119
- Object.assign(args, parsed[0]);
120
- delete args.patches;
121
- } else if (parsed && typeof parsed === "object" && parsed.path) {
122
- Object.assign(args, parsed);
123
- delete args.patches;
124
- }
125
- } catch { /* keep original */ }
126
- }
127
-
128
103
  // Edits serialized as JSON string
129
104
  if (typeof args.edits === "string") {
130
105
  try {
@@ -153,14 +128,6 @@ interface PatchCallComponent extends Box {
153
128
  previewArgsKey?: string;
154
129
  previewPending?: boolean;
155
130
  settledError: boolean;
156
- /** Overwrite streaming highlight cache (mirrors write tool design) */
157
- overwriteHighlightCache?: {
158
- rawPath: string;
159
- lang: string | undefined;
160
- rawContent: string;
161
- normalizedLines: string[];
162
- highlightedLines: string[];
163
- };
164
131
  }
165
132
 
166
133
  export function createPatchCallComponent(): PatchCallComponent {
@@ -184,88 +151,10 @@ function getPatchCallComponent(state: any, lastComponent: any): PatchCallCompone
184
151
  return comp;
185
152
  }
186
153
 
187
- // ─── Syntax highlighting for overwrite mode (mirrors write tool's incremental design) ──
188
-
189
- const OVERWRITE_PARTIAL_HIGHLIGHT_LINES = 50;
190
-
191
- function normalizeDisplayText(text: string): string {
192
- return text.replace(/\r/g, "");
193
- }
194
-
195
154
  function replaceTabs(text: string): string {
196
155
  return text.replace(/\t/g, " ");
197
156
  }
198
157
 
199
- function highlightSingleLine(line: string, lang: string | undefined): string {
200
- const highlighted = highlightCode(line, lang);
201
- return highlighted[0] ?? "";
202
- }
203
-
204
- function refreshOverwriteHighlightPrefix(cache: NonNullable<PatchCallComponent["overwriteHighlightCache"]>): void {
205
- const prefixCount = Math.min(OVERWRITE_PARTIAL_HIGHLIGHT_LINES, cache.normalizedLines.length);
206
- if (prefixCount === 0) return;
207
- const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
208
- const prefixHighlighted = highlightCode(prefixSource, cache.lang);
209
- for (let i = 0; i < prefixCount; i++) {
210
- cache.highlightedLines[i] =
211
- prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
212
- }
213
- }
214
-
215
- function rebuildOverwriteHighlightCache(rawPath: string, fileContent: string): PatchCallComponent["overwriteHighlightCache"] | undefined {
216
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
217
- if (!lang) return undefined;
218
- const normalized = replaceTabs(normalizeDisplayText(fileContent));
219
- return {
220
- rawPath,
221
- lang,
222
- rawContent: fileContent,
223
- normalizedLines: normalized.split("\n"),
224
- highlightedLines: highlightCode(normalized, lang),
225
- };
226
- }
227
-
228
- function updateOverwriteHighlightCache(
229
- cache: PatchCallComponent["overwriteHighlightCache"] | undefined,
230
- rawPath: string,
231
- fileContent: string,
232
- ): PatchCallComponent["overwriteHighlightCache"] | undefined {
233
- if (cache) {
234
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
235
- if (!lang || cache.lang !== lang || cache.rawPath !== rawPath) {
236
- return rebuildOverwriteHighlightCache(rawPath, fileContent);
237
- }
238
- if (!fileContent.startsWith(cache.rawContent)) {
239
- return rebuildOverwriteHighlightCache(rawPath, fileContent);
240
- }
241
- if (fileContent.length === cache.rawContent.length) return cache;
242
- const deltaRaw = fileContent.slice(cache.rawContent.length);
243
- const deltaNormalized = replaceTabs(normalizeDisplayText(deltaRaw));
244
- cache.rawContent = fileContent;
245
- if (cache.normalizedLines.length === 0) {
246
- cache.normalizedLines.push("");
247
- cache.highlightedLines.push("");
248
- }
249
- const segments = deltaNormalized.split("\n");
250
- const lastIndex = cache.normalizedLines.length - 1;
251
- cache.normalizedLines[lastIndex] += segments[0];
252
- cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex]!, cache.lang);
253
- for (let i = 1; i < segments.length; i++) {
254
- cache.normalizedLines.push(segments[i]!);
255
- cache.highlightedLines.push(highlightSingleLine(segments[i]!, cache.lang));
256
- }
257
- refreshOverwriteHighlightPrefix(cache);
258
- return cache;
259
- }
260
- return rebuildOverwriteHighlightCache(rawPath, fileContent);
261
- }
262
-
263
- function addHighlightedContent(parent: Box, lines: string[]): void {
264
- for (const line of lines) {
265
- parent.addChild(new Text(line, 0, 0));
266
- }
267
- }
268
-
269
158
  function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
270
159
  if (component.settledError) {
271
160
  return (text: string) => theme.bg("toolErrorBg", text);
@@ -335,9 +224,7 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
335
224
  let label = "";
336
225
  if (args?.path) {
337
226
  label = theme.fg("accent", args.path);
338
- if (args.overwrite) {
339
- label += theme.fg("error", " [overwrite]");
340
- } else if (args.edits?.length > 0) {
227
+ if (Array.isArray(args.edits) && args.edits.length > 0) {
341
228
  label += theme.fg("dim", ` (${args.edits.length} edit${args.edits.length > 1 ? "s" : ""})`);
342
229
  }
343
230
  }
@@ -348,9 +235,7 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
348
235
 
349
236
  const preview = component.preview;
350
237
  let body = "";
351
- if ("isOverwrite" in preview && preview.isOverwrite && preview.preview) {
352
- body = preview.preview;
353
- } else if ("diff" in preview && preview.diff) {
238
+ if ("diff" in preview && preview.diff) {
354
239
  body = preview.diff;
355
240
  } else if ("error" in preview && preview.error) {
356
241
  component.addChild(new Spacer(1));
@@ -364,36 +249,18 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
364
249
  const FOLD_THRESHOLD = 45;
365
250
 
366
251
  component.addChild(new Spacer(1));
367
- const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
368
252
 
369
253
  if (lines.length > FOLD_THRESHOLD && !expanded) {
370
254
  // 折叠态: 显示前几行 + 摘要
371
- if (isOverwrite) {
372
- if (component.overwriteHighlightCache) {
373
- const n = Math.min(10, component.overwriteHighlightCache.highlightedLines.length);
374
- addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines.slice(0, n));
375
- } else {
376
- component.addChild(new Text(theme.fg("toolDiffAdded", lines.slice(0, 10).join("\n")), 0, 0));
377
- }
378
- } else {
379
- const shown = lines.slice(0, 10).join("\n");
380
- appendPatchDiffChildren(component, shown, theme);
381
- }
255
+ const shown = lines.slice(0, 10).join("\n");
256
+ appendPatchDiffChildren(component, shown, theme);
382
257
  component.addChild(new Text(
383
258
  theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + keyHint("app.tools.expand", "expand") + theme.fg("dim", ")"),
384
259
  0, 0,
385
260
  ));
386
261
  } else {
387
262
  // 展开态: 完整显示
388
- if (isOverwrite) {
389
- if (component.overwriteHighlightCache) {
390
- addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines);
391
- } else {
392
- component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
393
- }
394
- } else {
395
- appendPatchDiffChildren(component, body, theme);
396
- }
263
+ appendPatchDiffChildren(component, body, theme);
397
264
  }
398
265
 
399
266
  return component;
@@ -401,17 +268,51 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
401
268
 
402
269
  // ─── Setup ──────────────────────────────────────────────────────────────────────────
403
270
 
271
+ export const OUTPUT_EXTERNALIZE_THRESHOLD = 30_000; // 30KB — match Claude Code's bash truncation threshold
272
+
273
+ /** If the tool result content is a single text string longer than the
274
+ * threshold, replace it with a one-line placeholder pointing at the
275
+ * full output on disk. Returns the modified result, or undefined to
276
+ * leave the original content untouched. */
277
+ export function maybeExternalizeToolResult(event: ToolResultEvent): ToolResultEventResult | undefined {
278
+ const content = event.content;
279
+ if (!Array.isArray(content) || content.length === 0) return undefined;
280
+ const first = content[0];
281
+ if (first?.type !== "text" || typeof first.text !== "string") return undefined;
282
+ const text = first.text;
283
+ if (text.length <= OUTPUT_EXTERNALIZE_THRESHOLD) return undefined;
284
+
285
+ const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
286
+ if (!filePath) return undefined; // write failed — keep original content
287
+
288
+ return {
289
+ content: [{
290
+ type: "text" as const,
291
+ text: `[Output truncated: ${text.length.toLocaleString()} chars. Full output: ${filePath}]`,
292
+ }],
293
+ };
294
+ }
295
+
404
296
  export function setupIO(pi: ExtensionAPI) {
405
297
  pi.on("session_start", (_event, ctx) => {
406
298
  restoreReadMarkersFromBranch(ctx.sessionManager.getBranch() as any[], ctx.cwd);
407
299
  const active = pi.getActiveTools();
408
- pi.setActiveTools(active.filter(t => !["edit", "write", "grep", "find", "ls"].includes(t)));
300
+ // Remove: edit (replaced by patch), grep/find/ls (replaced by bash)
301
+ // Keep: write (full-file write)
302
+ pi.setActiveTools(active.filter(t => !["edit", "grep", "find", "ls"].includes(t)));
409
303
  });
410
304
 
411
305
  pi.on("session_compact", () => {
412
306
  clearReadMarkers();
413
307
  });
414
308
 
309
+ // Externalize large tool results (read / bash) to a temp file.
310
+ // Keeps the messages segment small so prompt cache stays warm across turns.
311
+ pi.on("tool_result", (event) => {
312
+ if (event.toolName !== "read" && event.toolName !== "bash") return;
313
+ return maybeExternalizeToolResult(event);
314
+ });
315
+
415
316
  // Track file read times
416
317
  pi.on("tool_result", (event, ctx) => {
417
318
  if (!isReadToolResult(event)) return;
@@ -424,18 +325,31 @@ export function setupIO(pi: ExtensionAPI) {
424
325
  if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
425
326
  });
426
327
 
328
+ // Track file write times (for write and edit, in case patch module is disabled)
329
+ pi.on("tool_result", (event, ctx) => {
330
+ if (!isWriteToolResult(event) && !isEditToolResult(event)) return;
331
+ const filePath = event.input?.path;
332
+ if (typeof filePath !== "string" || !filePath.trim()) return;
333
+ const cwd: string = ctx.cwd ?? process.cwd();
334
+ const absPath = resolveAbsolutePath(cwd, filePath);
335
+ // Only record if the write succeeded (not an error result)
336
+ if (event.isError) return;
337
+ recordReadTime(absPath);
338
+ const marker = createFileTimeMarkerData(cwd, absPath);
339
+ if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
340
+ });
341
+
427
342
  pi.registerTool(defineTool({
428
343
  name: "patch",
429
344
  label: "Patch",
430
345
  description: [
431
- "Edits a file using exact string replacement, with anchor support and overwrite mode.",
346
+ "Edits a file using exact string replacement, with anchor support.",
432
347
  "When old_str is not unique, add more surrounding context or use anchor to narrow search.",
433
348
  "",
434
349
  "Examples:",
435
350
  ' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
436
351
  ' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return x + 1" }] }',
437
352
  ' { path: "src/foo.ts", edits: [{ anchor: "function init() {", old_str: "const DEBUG = true;", new_str: "const DEBUG = false;" }, { old_str: "log(\"debug\");", new_str: "// debug disabled" }] }',
438
- ' { path: "src/bar.ts", overwrite: true, new_str: "entire file content" }',
439
353
  "",
440
354
  "Anchor (optional): narrows old_str search to lines after a unique marker.",
441
355
  " Code: use the enclosing definition — function/class/struct/method signature.",
@@ -443,21 +357,20 @@ export function setupIO(pi: ExtensionAPI) {
443
357
  " Non-code (markdown, config, etc.): use section headings, key names, or distinctive lines.",
444
358
  ' e.g. "## API Reference" in .md or "[dependencies]" in .toml files.',
445
359
  ].join("\n"),
446
- promptSnippet: "Edits a file using exact string replacement, with anchor support and overwrite mode.",
360
+ promptSnippet: "Edits a file using exact string replacement, with anchor support.",
447
361
  promptGuidelines: [
448
- "Always prefer modifying files with PATCH tool over bash commands or python scripts.",
449
- "For full-file replacement, always use patch tool to prevent unintended edits or data loss.",
362
+ "Always prefer modifying files with patch tool over bash commands or python scripts.",
450
363
  "To prevent hallucinations: 1. Keep each edit batch ≤ 5 changes; 2. Process remaining revisions in sequential steps",
451
364
  "On repeated failures: read the file first to confirm information accuracy.",
452
365
  ],
453
366
  parameters: PatchSchema,
454
367
  renderShell: "self",
455
368
  prepareArguments: preparePatchArguments,
456
- execute: async (_toolCallId: string, input: { path: string; edits?: any[]; overwrite?: boolean; new_str?: string }, _signal: any, _onUpdate: any, ctx: any) => {
369
+ execute: async (_toolCallId: string, input: { path: string; edits: any[] }, _signal: any, _onUpdate: any, ctx: any) => {
457
370
  const cwd: string = ctx.cwd ?? process.cwd();
458
371
 
459
- // Stale-read protection (only for edits, not overwrite)
460
- if (!input.overwrite && input.path?.trim()) {
372
+ // Stale-read protection
373
+ if (input.path?.trim()) {
461
374
  const absPath = resolveAbsolutePath(cwd, input.path);
462
375
  const staleError = checkStaleFile(absPath, input.path);
463
376
  if (staleError) throw new Error(staleError);
@@ -491,28 +404,6 @@ export function setupIO(pi: ExtensionAPI) {
491
404
  component.previewArgsKey = argsKey;
492
405
  component.previewPending = false;
493
406
  component.settledError = false;
494
- component.overwriteHighlightCache = undefined;
495
- }
496
-
497
- // Overwrite streaming: incrementally highlight as new_str streams in
498
- if (args?.overwrite && typeof args?.path === "string" && typeof args?.new_str === "string") {
499
- if (context.argsComplete) {
500
- // Final: full rebuild for best quality
501
- component.overwriteHighlightCache = rebuildOverwriteHighlightCache(args.path, args.new_str);
502
- } else {
503
- // Streaming: incremental update
504
- component.overwriteHighlightCache = updateOverwriteHighlightCache(
505
- component.overwriteHighlightCache, args.path, args.new_str,
506
- );
507
- }
508
- // Inject highlighted content as synthetic preview for buildPatchCallComponent
509
- const cache = component.overwriteHighlightCache;
510
- if (cache) {
511
- component.preview = { preview: cache.rawContent, isOverwrite: true };
512
- } else {
513
- // No language detected: fall back to plain text
514
- component.preview = { preview: args.new_str, isOverwrite: true };
515
- }
516
407
  }
517
408
 
518
409
  // Preview diff is computed during execute and delivered via result.details.diff.
@@ -527,37 +418,17 @@ export function setupIO(pi: ExtensionAPI) {
527
418
  let changed = false;
528
419
 
529
420
  if (callComponent) {
530
- // overwrite: 保留完整文件预览,不要被空 diff 覆盖成只剩 header
531
- const overwriteContent = !context.isError && context.args?.overwrite && typeof context.args?.new_str === "string"
532
- ? context.args.new_str
533
- : undefined;
534
-
535
- if (typeof overwriteContent === "string") {
536
- const nextCache = rebuildOverwriteHighlightCache(context.args?.path, overwriteContent);
537
- const prevContent = (callComponent.overwriteHighlightCache?.rawContent ?? undefined);
538
- const prevPath = callComponent.overwriteHighlightCache?.rawPath;
539
- callComponent.overwriteHighlightCache = nextCache;
540
- if (
541
- !(callComponent.preview && "isOverwrite" in callComponent.preview && callComponent.preview.isOverwrite && callComponent.preview.preview === overwriteContent) ||
542
- prevContent !== overwriteContent ||
543
- prevPath !== context.args?.path
544
- ) {
545
- callComponent.preview = { preview: overwriteContent, isOverwrite: true };
421
+ // Use execute's returned diff (same design as Pi's native edit tool)
422
+ const resultDiff = !context.isError && result.details?.diff;
423
+ if (typeof resultDiff === "string") {
424
+ const newPreview = { diff: resultDiff };
425
+ if (callComponent.preview?.diff !== resultDiff) {
426
+ callComponent.preview = newPreview;
546
427
  changed = true;
547
428
  }
548
- } else {
549
- // 非 overwrite:优先使用 execute 返回的 diff(与 Pi 原生 edit 工具一致)
550
- const resultDiff = !context.isError && result.details?.diff;
551
- if (typeof resultDiff === "string") {
552
- const newPreview = { diff: resultDiff };
553
- if (callComponent.preview?.diff !== resultDiff) {
554
- callComponent.preview = newPreview;
555
- changed = true;
556
- }
557
- }
558
429
  }
559
430
 
560
- // 更新错误状态
431
+ // Update error state
561
432
  if (callComponent.settledError !== context.isError) {
562
433
  callComponent.settledError = context.isError;
563
434
  changed = true;
@@ -340,3 +340,37 @@ export function cleanupStaleCache(configs: McpServerConfig[], cwd?: string): voi
340
340
  }
341
341
  }
342
342
  }
343
+
344
+ // ── Large result externalization ────────────────────────────────────────
345
+
346
+ export const EXTERNALIZE_THRESHOLD = 50_000; // 50KB
347
+ export const EXTERNALIZE_PREVIEW_SIZE = 2_000; // 2KB preview
348
+ import { writeOutputToTemp } from "../io-tool-output.js";
349
+
350
+ /**
351
+ * If content exceeds threshold, save to temp file and return preview + path.
352
+ * Otherwise return original content unchanged.
353
+ * Falls back to returning full text if file write fails.
354
+ */
355
+ export function maybeExternalizeMcpResult(
356
+ text: string,
357
+ toolName: string,
358
+ toolCallId: string,
359
+ ): { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> } {
360
+ if (text.length <= EXTERNALIZE_THRESHOLD) {
361
+ return { content: [{ type: "text", text }], details: {} };
362
+ }
363
+
364
+ const filePath = writeOutputToTemp(toolName, toolCallId, text);
365
+ if (!filePath) {
366
+ return { content: [{ type: "text", text }], details: {} };
367
+ }
368
+ const preview = text.slice(0, EXTERNALIZE_PREVIEW_SIZE);
369
+ return {
370
+ content: [{ type: "text", text: `${preview}\n\n[Truncated: ${text.length.toLocaleString()} chars total. Full output saved to: ${filePath}]` }],
371
+ details: {
372
+ fullOutputPath: filePath,
373
+ truncation: { truncated: true, outputChars: EXTERNALIZE_PREVIEW_SIZE, totalChars: text.length, maxBytes: EXTERNALIZE_THRESHOLD },
374
+ },
375
+ };
376
+ }
@@ -4,6 +4,8 @@ import { McpConnection } from "./client.js";
4
4
  import {
5
5
  resolveMcpConfigs,
6
6
  loadMcpCache, updateServerCache, cleanupStaleCache,
7
+ maybeExternalizeMcpResult,
8
+ BUILTIN_MCP_SERVERS,
7
9
  type McpServerConfig, type McpToolCache,
8
10
  } from "./builtin.js";
9
11
 
@@ -51,7 +53,24 @@ function formatMcpResultText(text: string, expanded: boolean, theme: any): strin
51
53
  text,
52
54
  expanded ? Number.MAX_SAFE_INTEGER : MCP_RESULT_FOLD_LINES,
53
55
  );
54
- let rendered = displayLines.join("\n") ? theme.fg("toolOutput", displayLines.join("\n")) : "";
56
+
57
+ // Check for externalization message in the last line
58
+ const lastLine = displayLines[displayLines.length - 1] || "";
59
+ let outputLines = [...displayLines];
60
+ let truncationMsg = "";
61
+
62
+ if (lastLine.startsWith('[Truncated: ') && lastLine.endsWith(']')) {
63
+ truncationMsg = lastLine;
64
+ outputLines = outputLines.slice(0, -1);
65
+ }
66
+
67
+ const outputText = outputLines.join("\n");
68
+ let rendered = outputText ? theme.fg("toolOutput", outputText) : "";
69
+
70
+ if (truncationMsg) {
71
+ rendered += (rendered ? "\n" : "") + theme.fg("warning", truncationMsg);
72
+ }
73
+
55
74
  if (!expanded && remainingLines > 0) {
56
75
  rendered += `${theme.fg("muted", `\n... (${remainingLines} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`;
57
76
  }
@@ -100,10 +119,11 @@ function cacheScopeForSource(source: string): "global" | "project" {
100
119
 
101
120
  // ── register cached tools ─────────────────────────────────────────────────
102
121
 
103
- function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void {
104
- const cache = loadMcpCache(cachedCwd);
105
- if (!cache) return;
106
-
122
+ function registerCachedToolsFromCache(
123
+ pi: ExtensionAPI,
124
+ cache: McpCache,
125
+ configs: McpServerConfig[]
126
+ ): void {
107
127
  for (const config of configs) {
108
128
  if (!config.enabled) continue;
109
129
  const entry = cache.servers[config.name];
@@ -122,15 +142,19 @@ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void
122
142
  execute: async (_id, params, _signal, _update, _ctx) => {
123
143
  const conn = activeConnections.find(c => c.serverName === config.name);
124
144
  if (!conn) {
145
+ // Tool is registered from cache before connectAll finishes. Tell the
146
+ // model to retry — connection is establishing in the background.
147
+ const status = allServers.get(config.name);
148
+ const state = status?.state ?? "connecting";
125
149
  return {
126
- content: [{ type: "text", text: `MCP server "${config.name}" is not connected.` }],
127
- isError: true,
150
+ content: [{ type: "text", text: `MCP server "${config.name}" is ${state}. Please retry shortly.` }],
151
+ isError: false,
128
152
  details: {},
129
153
  };
130
154
  }
131
155
  try {
132
156
  const text = await conn.callTool(t.name, params as Record<string, unknown>);
133
- return { content: [{ type: "text", text }], isError: false, details: {} };
157
+ return maybeExternalizeMcpResult(text, toolName, _id);
134
158
  } catch (err) {
135
159
  const msg = err instanceof Error ? err.message : String(err);
136
160
  return {
@@ -145,9 +169,18 @@ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void
145
169
  }
146
170
  }
147
171
 
172
+ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void {
173
+ const cache = loadMcpCache(cachedCwd);
174
+ if (!cache) return;
175
+ registerCachedToolsFromCache(pi, cache, configs);
176
+ }
177
+
148
178
  // ── connect ───────────────────────────────────────────────────────────────
149
179
 
150
- async function connectAll(configs: McpServerConfig[], ui?: { notify: (msg: string, type: string) => void }): Promise<void> {
180
+ async function connectAll(
181
+ configs: McpServerConfig[],
182
+ ui?: { notify: (msg: string, type: string) => void }
183
+ ): Promise<{ schemaChanges: string[]; hasNewServer: boolean }> {
151
184
  // Load current cache for comparison
152
185
  const cache = loadMcpCache(cachedCwd);
153
186
  const schemaChanges: string[] = [];
@@ -241,10 +274,17 @@ async function connectAll(configs: McpServerConfig[], ui?: { notify: (msg: strin
241
274
  await connectPromise;
242
275
  connectPromise = null;
243
276
 
244
- // Notify about schema changes
245
- if (schemaChanges.length > 0 && ui) {
246
- ui.notify(`MCP schema updated: ${schemaChanges.join('; ')}. Run /reload to apply.`, "warning");
277
+ // Determine whether any server had no cached tools (first-time connection).
278
+ let hasNewServer = false;
279
+ for (const server of configs) {
280
+ const cachedEntry = cache?.servers[server.name];
281
+ if (!cachedEntry || cachedEntry.tools.length === 0) {
282
+ hasNewServer = true;
283
+ break;
284
+ }
247
285
  }
286
+
287
+ return { schemaChanges, hasNewServer };
248
288
  }
249
289
 
250
290
  // ── setup ─────────────────────────────────────────────────────────────────
@@ -263,11 +303,27 @@ export function setupMcp(pi: ExtensionAPI) {
263
303
 
264
304
  const enabledConfigs = configs.filter(s => s.enabled);
265
305
 
266
- // Register tools from cache prompt-stable, works even if MCP is down
267
- registerCachedTools(pi, enabledConfigs);
306
+ // Load cache and register tools immediately (before connectAll)
307
+ const cache = loadMcpCache(cachedCwd);
308
+ if (cache) {
309
+ registerCachedToolsFromCache(pi, cache, enabledConfigs);
310
+ }
268
311
 
269
- // Connect in background, pass UI for schema change notifications
270
- void connectAll(enabledConfigs, ctx.hasUI ? ctx.ui : undefined);
312
+ // Connect in background (fire-and-forget, don't block session_start)
313
+ void connectAll(enabledConfigs, ctx.hasUI ? ctx.ui : undefined).then(({ schemaChanges, hasNewServer }) => {
314
+ // Re-register tools when:
315
+ // - A server was not in cache before (first connection)
316
+ // - Schema changed (tools added/removed/changed)
317
+ if (hasNewServer || schemaChanges.length > 0) {
318
+ const cache = loadMcpCache(cachedCwd);
319
+ if (cache) {
320
+ registerCachedToolsFromCache(pi, cache, enabledConfigs);
321
+ }
322
+ }
323
+ if (schemaChanges.length > 0 && ctx.hasUI) {
324
+ ctx.ui.notify(`MCP schema updated: ${schemaChanges.join('; ')}.`, "info");
325
+ }
326
+ });
271
327
  });
272
328
 
273
329
  pi.on("session_shutdown", () => {
@@ -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
- let content = normalizeLineEndings(rawContent);
292
+ const originalContent = normalizeLineEndings(rawContent);
196
293
 
197
- // Precompute line offsets for O(log n) line number lookups
198
- const lineOffsets = buildLineOffsets(rawContent);
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
- // Track cumulative offset for mapping current positions back to original
202
- let cumulativeOffset = 0;
203
- const replacements: ReplacementInfo[] = [];
204
- const neededRanges: LineRange[] = [];
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
- let oldNorm = normalizeLineEndings(edit.old_str);
212
- let newNorm = normalizeLineEndings(edit.new_str);
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
+ }
213
319
 
214
- // Determine search range
215
- let searchFrom = 0;
216
- let displayAnchor: string | undefined;
217
- let anchorMissing = false;
218
- let anchorNotFoundMessage: string | undefined;
320
+ planned.push(located);
321
+ }
219
322
 
220
- if (edit.anchor) {
221
- const anchorNorm = normalizeLineEndings(edit.anchor);
323
+ // ═══════════════════════════════════════════════════════════════════
324
+ // Sequential fallback — old behaviour for chained edits
325
+ // ═══════════════════════════════════════════════════════════════════
222
326
 
223
- // Find anchor — must be unique when present
224
- const anchorIdx = content.indexOf(anchorNorm);
225
- if (anchorIdx === -1) {
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
- }
238
-
239
- // Find old_str in range — must be unique
240
- let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
241
- if (matchIdx === -1 && anchorNotFoundMessage) {
242
- // Anchor was missing/unusable — try global exact match first
243
- displayAnchor = edit.anchor;
244
- anchorMissing = true;
245
- matchIdx = content.indexOf(oldNorm, 0);
246
- if (matchIdx !== -1) {
247
- const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
248
- if (secondGlobalMatch !== -1) {
249
- const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
250
- throw new ApplyError(`${anchorNotFoundMessage}\n${dupDiag}`);
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
- // Check uniqueness in anchor-narrowed / plain search path only when anchor was used normally
279
- if (!anchorNotFoundMessage) {
280
- const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
281
- if (secondMatch !== -1) {
282
- const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
283
- throw new ApplyError(
284
- `${dupDiag}`
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
- // Compute line numbers in the original file for diff generation (O(log n) via binary search)
290
- // matchIdx is in the modified content; subtract cumulative offset to map back to original
291
- const origMatchIdx = matchIdx - cumulativeOffset;
292
- const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
293
- const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
388
+ const fileDiff = generateLocalDiff(displayPath, cleanReplacements, neededLines, totalLines);
389
+ if (result.diff) {
390
+ result.diff += "\n" + fileDiff;
391
+ } else {
392
+ result.diff = fileDiff;
393
+ }
294
394
 
295
- // Apply replacement
296
- content =
297
- content.substring(0, matchIdx) +
298
- newNorm +
299
- content.substring(matchIdx + oldNorm.length);
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
+ }
300
430
 
301
- // Track the offset shift for subsequent edits
302
- cumulativeOffset += newNorm.length - oldNorm.length;
431
+ // ═══════════════════════════════════════════════════════════════════
432
+ // Phase 3: One-shot assembly — splice replacements into final content
433
+ // ═══════════════════════════════════════════════════════════════════
303
434
 
304
- // Compute new_str line numbers in the result
305
- const newStartLine = charOffsetToLine(content, matchIdx);
306
- const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
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);
443
+
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;
448
+
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
- // Generate diff using only needed context lines (no full-file split)
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 currentLineOffsets = buildLineOffsets(content);
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(content, currentLineOffsets, range.startLine, range.endLine);
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
  }
@@ -560,14 +711,17 @@ interface ChunkAnchor {
560
711
  function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
561
712
  const byText = new Map<string, ChunkAnchor>();
562
713
  for (const rep of chunk.reps) {
563
- const text = rep.anchor?.trim();
564
- if (!text) continue;
565
- const existing = byText.get(text);
566
- if (!existing) {
567
- byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
568
- } else if (!rep.anchorMissing) {
569
- // If any replacement successfully used this anchor, do not mark it missing.
570
- existing.missing = false;
714
+ const raw = rep.anchor?.trim();
715
+ if (!raw) continue;
716
+ // Support \n-separated anchors from collapsed sequential replacements
717
+ const texts = raw.includes("\n") ? raw.split("\n").map(s => s.trim()).filter(Boolean) : [raw];
718
+ for (const text of texts) {
719
+ const existing = byText.get(text);
720
+ if (!existing) {
721
+ byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
722
+ } else if (!rep.anchorMissing) {
723
+ existing.missing = false;
724
+ }
571
725
  }
572
726
  }
573
727
  return [...byText.values()];
@@ -795,6 +949,76 @@ function charOffsetToLine(content: string, offset: number): number {
795
949
  /**
796
950
  * Generate diff using only the needed lines (partial file context).
797
951
  */
952
+ /** Collapse chained-edit replacements (where out[i] === in[i+1]) into
953
+ * net-change replacements showing only the net effect (original→final). */
954
+ function collapseSequentialReplacements(
955
+ reps: ReplacementInfo[],
956
+ ): ReplacementInfo[] {
957
+ const collapsed: ReplacementInfo[] = [];
958
+ let i = 0;
959
+ while (i < reps.length) {
960
+ const start = reps[i]!;
961
+ let merged: ReplacementInfo = {
962
+ ...start,
963
+ newStartLine: start.oldStartLine,
964
+ newEndLine: start.oldStartLine + start.newLines.length - 1,
965
+ };
966
+
967
+ const anchors: string[] = [];
968
+ const seenAnchors = new Set<string>();
969
+ const addAnchor = (raw?: string) => {
970
+ if (!raw) return;
971
+ for (const text of raw.split("\n").map(s => s.trim()).filter(Boolean)) {
972
+ if (seenAnchors.has(text)) continue;
973
+ seenAnchors.add(text);
974
+ anchors.push(text);
975
+ }
976
+ };
977
+ addAnchor(start.anchor);
978
+
979
+ let j = i + 1;
980
+ while (j < reps.length) {
981
+ const next = reps[j]!;
982
+ // Merge chained edits when next edit's input matches merged output.
983
+ // We allow slightly shifted line numbers because sequential edits can
984
+ // change string lengths before we compute displayed line ranges.
985
+ if (!(linesEqual(merged.newLines, next.oldLines) && next.oldStartLine <= merged.oldEndLine + 1)) {
986
+ break;
987
+ }
988
+ addAnchor(next.anchor);
989
+ merged = {
990
+ // Keep the ORIGINAL region from the first replacement in the chain.
991
+ // Later chained replacements may have shifted line numbers, but the
992
+ // net diff should still point at the original file region.
993
+ oldStartLine: merged.oldStartLine,
994
+ oldEndLine: merged.oldEndLine,
995
+ newStartLine: merged.oldStartLine,
996
+ newEndLine: merged.oldStartLine + next.newLines.length - 1,
997
+ oldLines: merged.oldLines,
998
+ newLines: next.newLines,
999
+ anchor: undefined,
1000
+ anchorMissing: merged.anchorMissing || next.anchorMissing,
1001
+ };
1002
+ j++;
1003
+ }
1004
+
1005
+ collapsed.push({
1006
+ ...merged,
1007
+ anchor: anchors.length > 0 ? anchors.join("\n") : undefined,
1008
+ });
1009
+ i = j;
1010
+ }
1011
+ return collapsed;
1012
+ }
1013
+
1014
+ function linesEqual(a: string[], b: string[]): boolean {
1015
+ if (a.length !== b.length) return false;
1016
+ for (let i = 0; i < a.length; i++) {
1017
+ if (a[i] !== b[i]) return false;
1018
+ }
1019
+ return true;
1020
+ }
1021
+
798
1022
  function generateLocalDiff(
799
1023
  filePath: string,
800
1024
  reps: ReplacementInfo[],
@@ -255,8 +255,11 @@ class McpStatusComponent extends Container {
255
255
  if (s.tools.length > 0) {
256
256
  lines.push(` ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}:`);
257
257
  for (const tool of s.tools.slice(0, 6)) {
258
- const td = tool.description
259
- ? ` ${tool.description.slice(0, 55)}`
258
+ const flat = (tool.description ?? "").replace(/\s+/g, " ").trim();
259
+ const td = flat
260
+ ? flat.length > 55
261
+ ? ` — ${flat.slice(0, 55)}…`
262
+ : ` — ${flat}`
260
263
  : "";
261
264
  lines.push(` ${tool.name}${td}`);
262
265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -13,7 +13,6 @@
13
13
  "language-server",
14
14
  "secret-detection"
15
15
  ],
16
- "type": "commonjs",
17
16
  "license": "MIT",
18
17
  "repository": {
19
18
  "type": "git",
@@ -46,6 +45,6 @@
46
45
  "scripts": {
47
46
  "prepack": "depcheck --fail-on-missing",
48
47
  "test": "vitest run",
49
- "prepublishOnly": "npm install --package-lock-only --no-audit --no-fund && npm test && depcheck --fail-on-missing && publint"
48
+ "prepublishOnly": "npm install --no-audit --no-fund && npm test && depcheck --fail-on-missing && publint"
50
49
  }
51
50
  }