decorated-pi 0.3.0 → 0.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # decorated-pi
2
2
 
3
- `decorated-pi` is a Pi extension that adds stronger edit tool, safety gates, LSP tools, image/compaction model helpers, smarter `@` file search, dynamic subdirectory `AGENTS.md` loading, and a few workflow quality-of-life improvements.
3
+ `decorated-pi` is a practical enhancement pack for [Pi](https://github.com/earendil-works/pi).
4
4
 
5
5
  ## Install
6
6
 
@@ -20,24 +20,45 @@ Replaces Pi's built-in `edit` / `write` with a stronger `patch` tool:
20
20
  - **mtime tracking** — records file modification time on `read`, rejects `patch` if the file changed since last read, preventing blind or stale edits
21
21
  - **explicit overwrite** — offer atomic `overwrite: true` mode for overwrite files or full-file creation to prevent unintened overwrite
22
22
 
23
- ### 2. Smart `@` File Search
23
+ ### 2. Secret redaction
24
24
 
25
- Replaces Pi's default file search with a faster, project-aware search strategy:
25
+ Three-layer detection: high-confidence known-format patterns (AWS, GitHub, OpenAI, etc.), config-key regex matching, and adjusted Shannon entropy heuristics for unknown secret-like values.
26
26
 
27
- - Uses project-aware file discovery
28
- - Prioritizes filename-based matches for more intuitive results
29
- - Reduces clutter from hidden, cache, and build directories
30
- - Keeps default suggestions focused on visible project files
27
+ ### 3. Built-in MCP Client
28
+
29
+ Zero-config MCP client with two built-in servers:
31
30
 
32
- ### 3. LSP Tool Suite
31
+ | Server | Tool Prefix | Source |
32
+ | --- | --- | --- |
33
+ | Context7 | `context7_*` | `https://mcp.context7.com/mcp` |
34
+ | Exa | `exa_*` | `https://mcp.exa.ai/mcp` |
33
35
 
34
- Based on [@spences10/pi-lsp](https://github.com/spences10/my-pi/tree/main/packages/pi-lsp), with major additions:
36
+ **Project-level config** — add custom MCP servers by placing an `mcp.json` or `.mcp.json` (also `.pi/mcp.json`, `.agents/mcp.json`, `.claude/mcp.json` and their `.`-prefixed variants) anywhere in your project tree. Servers found closer to `cwd` take precedence over ancestor directories.
35
37
 
36
- - C/C++ and Lua support
37
- - `lsp_find_symbol`, `lsp_rename`, multi-file support merged into `lsp_diagnostics`
38
- - Force-sync on `didChange` (no stale diagnostics)
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "my-server": {
42
+ "url": "https://my-mcp.example.com/mcp",
43
+ "enabled": true
44
+ }
45
+ }
46
+ }
47
+ ```
39
48
 
40
- Supported languages: c/cpp, go, java, lua, python, ruby, rust, svelte, typescript
49
+ **Global config** persist custom servers in `~/.pi/agent/decorated-pi.json`:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "my-server": { "url": "https://my-mcp.example.com/mcp" }
55
+ }
56
+ }
57
+ ```
58
+
59
+ **Priority**: project > global > builtin. A server with the same name from a higher-priority source overrides lower ones.
60
+
61
+ Use `/mcp` to view connection status and registered tools.
41
62
 
42
63
  ### 4. Auxiliary Models (Image + Compact)
43
64
 
@@ -46,31 +67,32 @@ Uses cheaper models for auxiliary tasks, configured via `/dp-model`:
46
67
  - **Image read fallback** — when the model reads an image file, detects type via magic bytes, calls a configured vision-capable model, and replaces the read result with image analysis text (jpeg, png, gif, webp)
47
68
  - **Compact model** — uses a configured model for context compaction (instead of the main model), auto-resumes after compaction.
48
69
 
49
- ### 5. Safety Layer
50
-
51
- - **Dangerous bash guard**
52
- - asks for confirmation on destructive commands such as:
53
- - `rm`
54
- - `sudo`
55
- - `svn commit/revert`
56
- - `git reset/restore/clean/push/revert`
57
- - `npm publish`
58
- - `>` / `1>` / `2>` / `&>` / `tee` overwrite existing files
59
- - Hints the agent to use `edit` instead of `write` on non-empty files
60
- - **Protected paths**
61
- - Blocks read/write access (via `write`/`edit`/`read` tools or `cat`/`head`/`tail`/`grep`/`rg` etc. bash commands) to sensitive locations such as `.env`, `.git/`, `.ssh/`, `*.pem`, `*.key`, etc.
62
- - **Secret redaction**
63
- - Three-layer detection: high-confidence known-format patterns (AWS, GitHub, OpenAI, etc.), config-key regex matching, and adjusted Shannon entropy heuristics for unknown secret-like values.
64
- - Based on [opencode-secrets-protect](https://github.com/jscheel/opencode-secrets-protect)
65
-
66
- ### 6. Dynamic Subdirectory `AGENTS.md` / `CLAUDE.md`
70
+ ### 5. Smart `@` File Search
71
+
72
+ Replaces Pi's default file search with a faster, project-aware search strategy:
73
+
74
+ - Uses project-aware file discovery
75
+ - Prioritizes filename-based matches for more intuitive results
76
+ - Reduces clutter from hidden, cache, and build directories
77
+ - Keeps default suggestions focused on visible project files
78
+
79
+ ### 6. LSP Tool Suite
80
+
81
+ A cleaned-up, minimal LSP toolset. The extension keeps only the two LSP tools that cover the most practical coding workflows: checking diagnostics after edits and inspecting file structure before focused changes.
82
+
83
+ - **`lsp_diagnostics`** — file diagnostics with severity filtering
84
+ - **`lsp_document_symbols`** file symbol outline
85
+
86
+ Supported languages: c/cpp, go, java, lua, json, python, ruby, rust, svelte, typescript
87
+
88
+ ### 7. Dynamic Subdirectory `AGENTS.md` / `CLAUDE.md`
67
89
 
68
90
  When the agent reads or edits a file:
69
91
 
70
92
  - discovers `AGENTS.md` / `CLAUDE.md` in the file's directory and ancestor directories
71
93
  - injects newly discovered guidance into tool results
72
94
 
73
- ### 7. Extend Providers
95
+ ### 8. Extend Providers
74
96
 
75
97
  Extend providers are registered via `/login` → "Use a subscription":
76
98
 
@@ -95,9 +117,10 @@ Modules can be toggled on/off. Changes take effect after `/reload`.
95
117
  | Module | Default | Effect when disabled |
96
118
  | -------- | --------- | --------------------- |
97
119
  | `patch` | `true` | Reverts to Pi's built-in `edit` / `write` tools |
98
- | `safety` | `true` | No command guard, no protected path check, no secret redaction |
120
+ | `safety` | `true` | No secret redaction on `read` / `bash` output |
99
121
  | `lsp` | `true` | All `lsp_*` tools unregistered — no diagnostics, hover, etc. |
100
122
  | `smart-at` | `true` | Fallback to Pi's built-in `@` file completion |
123
+ | `mcp` | `true` | All `{server}_*` MCP tools unregistered |
101
124
 
102
125
  Use `/dp-settings` to toggle, or edit the config file directly:
103
126
 
@@ -107,7 +130,8 @@ Use `/dp-settings` to toggle, or edit the config file directly:
107
130
  "patch": true,
108
131
  "safety": true,
109
132
  "lsp": false,
110
- "smart-at": true
133
+ "smart-at": true,
134
+ "mcp": true
111
135
  }
112
136
  }
113
137
  ```
@@ -4,11 +4,26 @@
4
4
  * - `read` tool records mtime when the LLM reads a file
5
5
  * - `patch` tool checks mtime before editing — rejects if file changed since last read
6
6
  * - `patch` tool updates mtime after a successful write
7
+ * - Markers are persisted to session custom entries and restored from the
8
+ * current branch after the last compaction boundary.
7
9
  */
8
10
 
9
11
  import * as fs from "node:fs";
10
12
  import * as path from "node:path";
11
13
 
14
+ export const FILE_TIMES_CUSTOM_TYPE = "decorated-pi.file-times";
15
+
16
+ export interface FileTimeMarkerData {
17
+ path: string;
18
+ mtimeMs: number;
19
+ }
20
+
21
+ interface SessionLikeEntry {
22
+ type: string;
23
+ customType?: string;
24
+ data?: unknown;
25
+ }
26
+
12
27
  /** Last-known mtime for each absolute file path (ms since epoch). */
13
28
  const readMarkers = new Map<string, number>();
14
29
 
@@ -54,7 +69,50 @@ export function checkStaleFile(absPath: string, displayPath: string): string | u
54
69
  return undefined;
55
70
  }
56
71
 
57
- /** Clear all tracked file times (e.g., on session start). */
72
+ function toStoredPath(cwd: string, absPath: string): string {
73
+ const normalizedCwd = path.normalize(cwd);
74
+ const normalizedAbs = path.normalize(absPath);
75
+ const rel = path.relative(normalizedCwd, normalizedAbs);
76
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel;
77
+ return normalizedAbs;
78
+ }
79
+
80
+ function lastCompactionIndex(entries: SessionLikeEntry[]): number {
81
+ for (let i = entries.length - 1; i >= 0; i--) {
82
+ if (entries[i]?.type === "compaction") return i;
83
+ }
84
+ return -1;
85
+ }
86
+
87
+ function isFileTimeMarkerData(value: unknown): value is FileTimeMarkerData {
88
+ return !!value
89
+ && typeof value === "object"
90
+ && typeof (value as any).path === "string"
91
+ && typeof (value as any).mtimeMs === "number";
92
+ }
93
+
94
+ /** Build a session-persisted marker payload for the current file version. */
95
+ export function createFileTimeMarkerData(cwd: string, absPath: string): FileTimeMarkerData | undefined {
96
+ if (!fs.existsSync(absPath)) return undefined;
97
+ return {
98
+ path: toStoredPath(cwd, absPath),
99
+ mtimeMs: getFileMtime(absPath),
100
+ };
101
+ }
102
+
103
+ /** Restore markers from the current branch, ignoring anything before the last compaction. */
104
+ export function restoreReadMarkersFromBranch(entries: SessionLikeEntry[], cwd: string): void {
105
+ clearReadMarkers();
106
+ const start = lastCompactionIndex(entries) + 1;
107
+ for (const entry of entries.slice(start)) {
108
+ if (entry.type !== "custom" || entry.customType !== FILE_TIMES_CUSTOM_TYPE) continue;
109
+ if (!isFileTimeMarkerData(entry.data)) continue;
110
+ const absPath = resolveAbsolutePath(cwd, entry.data.path);
111
+ readMarkers.set(absPath, entry.data.mtimeMs);
112
+ }
113
+ }
114
+
115
+ /** Clear all tracked file times (e.g., on session start or compaction). */
58
116
  export function clearReadMarkers(): void {
59
117
  readMarkers.clear();
60
118
  }
@@ -63,4 +121,4 @@ export function clearReadMarkers(): void {
63
121
  export function resolveAbsolutePath(cwd: string, filePath: string): string {
64
122
  if (path.isAbsolute(filePath)) return path.normalize(filePath);
65
123
  return path.normalize(path.resolve(cwd, filePath));
66
- }
124
+ }
@@ -9,9 +9,11 @@ export function setupGuidance(pi: ExtensionAPI) {
9
9
  const guidance = [
10
10
  DECORATED_PI_GUIDANCE_MARKER,
11
11
  "",
12
- "Before taking any action on a user's prompt, briefly restate your understanding of what the user wants. If ambiguous, ask clarifying questions. Only proceed after intent is clear.",
13
- "",
14
- "For medium-to-large tasks (more than 3 steps or touching multiple files/systems), break the task into discrete steps. For small tasks (1-2 steps), do it directly.",
12
+ "- Before acting on a user's prompt, ensure you fully understand their needs. If the intent is ambiguous, ask clarifying questions. Proceed only when the intent is clear.",
13
+ "- Look before you leap! Ensure you have conducted thorough research before taking any action.",
14
+ "- Exercise caution when performing any **write** operations, especially when you are in a research or exploration phase.",
15
+ "- You don't need to read **AGENTS.md** or **CLAUDE.md** files unless you're explicitly asked to, these files will loaded automatically if neccessary.",
16
+ "- CAUTION: Do not perform write operations in the following directories unless explicitly instructed: `node_modules`, `venv`, `env`, `__pycache__`, `.git` or any other hidden directories.",
15
17
  ].join("\n");
16
18
 
17
19
  return {
@@ -13,6 +13,7 @@ import { setupGuidance } from "./guidance";
13
13
  import { setupLsp } from "./lsp/index";
14
14
  import { setupProviders } from "./providers/index";
15
15
  import { setupSmartAt } from "./smart-at";
16
+ import { setupMcp } from "./mcp/index.js";
16
17
  import { isModuleEnabled } from "./settings";
17
18
 
18
19
  export default function (pi: ExtensionAPI) {
@@ -29,4 +30,5 @@ export default function (pi: ExtensionAPI) {
29
30
  if (isModuleEnabled("safety")) setupSafety(pi);
30
31
  if (isModuleEnabled("lsp")) setupLsp(pi);
31
32
  if (isModuleEnabled("smart-at")) setupSmartAt(pi);
33
+ if (isModuleEnabled("mcp")) setupMcp(pi);
32
34
  }
package/extensions/io.ts CHANGED
@@ -18,11 +18,15 @@
18
18
  * 2. TUI rendering MUST mirror the edit tool pattern exactly
19
19
  * 3. getPatchHeaderBg: settledError MUST be checked first
20
20
  * 4. renderResult must NOT return the Box
21
+ * renderCall returns the Box (callComponent). If renderResult
22
+ * also returns it, pi's ToolExecutionComponent adds it twice
23
+ * to the container, causing duplicate boxes. renderResult
24
+ * must return context.lastComponent (a separate Container).
21
25
  * 5. Error text must go INSIDE the Box, not in the result Container
22
26
  * 6. prepareArguments must handle literal newlines in JSON strings
23
27
  */
24
28
 
25
- import { defineTool, isReadToolResult, keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import { defineTool, isReadToolResult, keyHint, getLanguageFromPath, highlightCode, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
30
  import { renderDiff } from "@earendil-works/pi-coding-agent";
27
31
  import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
28
32
  import { Type } from "typebox";
@@ -30,10 +34,17 @@ import {
30
34
  applyPatch,
31
35
  formatPatchResult,
32
36
  generatePatchDiff,
33
- computePatchPreview,
34
37
  type PatchPreview,
35
38
  } from "./patch.js";
36
- import { recordReadTime, checkStaleFile, clearReadMarkers, resolveAbsolutePath } from "./file-times.js";
39
+ import {
40
+ recordReadTime,
41
+ checkStaleFile,
42
+ clearReadMarkers,
43
+ resolveAbsolutePath,
44
+ FILE_TIMES_CUSTOM_TYPE,
45
+ createFileTimeMarkerData,
46
+ restoreReadMarkersFromBranch,
47
+ } from "./file-times.js";
37
48
 
38
49
  // ─── Schema ─────────────────────────────────────────────────────────────────────────
39
50
 
@@ -142,9 +153,17 @@ interface PatchCallComponent extends Box {
142
153
  previewArgsKey?: string;
143
154
  previewPending?: boolean;
144
155
  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
+ };
145
164
  }
146
165
 
147
- function createPatchCallComponent(): PatchCallComponent {
166
+ export function createPatchCallComponent(): PatchCallComponent {
148
167
  return Object.assign(new Box(1, 1, (text: string) => text), {
149
168
  preview: undefined,
150
169
  previewArgsKey: undefined,
@@ -165,6 +184,88 @@ function getPatchCallComponent(state: any, lastComponent: any): PatchCallCompone
165
184
  return comp;
166
185
  }
167
186
 
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
+ function replaceTabs(text: string): string {
196
+ return text.replace(/\t/g, " ");
197
+ }
198
+
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
+
168
269
  function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
169
270
  if (component.settledError) {
170
271
  return (text: string) => theme.bg("toolErrorBg", text);
@@ -187,6 +288,14 @@ function createSingleLineComponent(text: string) {
187
288
  };
188
289
  }
189
290
 
291
+ function formatPatchMetaLine(line: string, theme: any): string {
292
+ const missingSuffix = " (missing)";
293
+ if (line.endsWith(missingSuffix)) {
294
+ return theme.fg("accent", line.slice(0, -missingSuffix.length)) + theme.fg("warning", missingSuffix);
295
+ }
296
+ return theme.fg("accent", line);
297
+ }
298
+
190
299
  function appendPatchDiffChildren(parent: Box, body: string, theme: any): void {
191
300
  const rawLines = body.split("\n");
192
301
  let buffer: string[] = [];
@@ -200,17 +309,17 @@ function appendPatchDiffChildren(parent: Box, body: string, theme: any): void {
200
309
  for (const line of rawLines) {
201
310
  if (line.startsWith("@@ lines ")) {
202
311
  flush();
203
- parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
312
+ parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
204
313
  continue;
205
314
  }
206
315
  if (line === "anchors:") {
207
316
  flush();
208
- parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
317
+ parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
209
318
  continue;
210
319
  }
211
320
  if (line.startsWith(" - ")) {
212
321
  flush();
213
- parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
322
+ parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
214
323
  continue;
215
324
  }
216
325
  buffer.push(line);
@@ -219,7 +328,7 @@ function appendPatchDiffChildren(parent: Box, body: string, theme: any): void {
219
328
  flush();
220
329
  }
221
330
 
222
- function buildPatchCallComponent(component: PatchCallComponent, args: any, theme: any, expanded = false) {
331
+ export function buildPatchCallComponent(component: PatchCallComponent, args: any, theme: any, expanded = false) {
223
332
  component.setBgFn(getPatchHeaderBg(component, theme));
224
333
  component.clear();
225
334
 
@@ -255,13 +364,19 @@ function buildPatchCallComponent(component: PatchCallComponent, args: any, theme
255
364
  const FOLD_THRESHOLD = 45;
256
365
 
257
366
  component.addChild(new Spacer(1));
367
+ const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
368
+
258
369
  if (lines.length > FOLD_THRESHOLD && !expanded) {
259
370
  // 折叠态: 显示前几行 + 摘要
260
- const shown = lines.slice(0, 10).join("\n");
261
- const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
262
371
  if (isOverwrite) {
263
- component.addChild(new Text(theme.fg("toolDiffAdded", shown), 0, 0));
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
+ }
264
378
  } else {
379
+ const shown = lines.slice(0, 10).join("\n");
265
380
  appendPatchDiffChildren(component, shown, theme);
266
381
  }
267
382
  component.addChild(new Text(
@@ -270,9 +385,12 @@ function buildPatchCallComponent(component: PatchCallComponent, args: any, theme
270
385
  ));
271
386
  } else {
272
387
  // 展开态: 完整显示
273
- const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
274
388
  if (isOverwrite) {
275
- component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
389
+ if (component.overwriteHighlightCache) {
390
+ addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines);
391
+ } else {
392
+ component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
393
+ }
276
394
  } else {
277
395
  appendPatchDiffChildren(component, body, theme);
278
396
  }
@@ -284,10 +402,14 @@ function buildPatchCallComponent(component: PatchCallComponent, args: any, theme
284
402
  // ─── Setup ──────────────────────────────────────────────────────────────────────────
285
403
 
286
404
  export function setupIO(pi: ExtensionAPI) {
287
- pi.on("session_start", () => {
288
- clearReadMarkers();
405
+ pi.on("session_start", (_event, ctx) => {
406
+ restoreReadMarkersFromBranch(ctx.sessionManager.getBranch() as any[], ctx.cwd);
289
407
  const active = pi.getActiveTools();
290
- pi.setActiveTools(active.filter(t => !["edit", "write"].includes(t)));
408
+ pi.setActiveTools(active.filter(t => !["edit", "write", "grep", "find", "ls"].includes(t)));
409
+ });
410
+
411
+ pi.on("session_compact", () => {
412
+ clearReadMarkers();
291
413
  });
292
414
 
293
415
  // Track file read times
@@ -298,24 +420,28 @@ export function setupIO(pi: ExtensionAPI) {
298
420
  const cwd: string = ctx.cwd ?? process.cwd();
299
421
  const absPath = resolveAbsolutePath(cwd, filePath);
300
422
  recordReadTime(absPath);
423
+ const marker = createFileTimeMarkerData(cwd, absPath);
424
+ if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
301
425
  });
302
426
 
303
427
  pi.registerTool(defineTool({
304
428
  name: "patch",
305
429
  label: "Patch",
306
430
  description: [
307
- "Edits a file using exact string replacement, with optional anchor to narrow search.",
431
+ "Edits a file using exact string replacement, with anchor support and overwrite mode.",
308
432
  "When old_str is not unique, add more surrounding context or use anchor to narrow search.",
309
- "If possible, prefer anchor for more robust edits. Anchor must be a unique string that appears BEFORE old_str in the file.",
310
433
  "",
311
434
  "Examples:",
312
- ' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return y" }] }',
313
435
  ' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
436
+ ' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return x + 1" }] }',
437
+ ' { 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" }] }',
314
438
  ' { path: "src/bar.ts", overwrite: true, new_str: "entire file content" }',
315
- "Anchor examples (provide enough context to make it unique):",
316
- ' prefer "function foo()" over "foo".',
317
- ' prefer "class Foo" over "Foo".',
318
- ' prefer "## Section Title" in md file edits.',
439
+ "",
440
+ "Anchor (optional): narrows old_str search to lines after a unique marker.",
441
+ " Code: use the enclosing definition — function/class/struct/method signature.",
442
+ ' e.g. "function handleClick() {" or "class UserService {" or "struct Config {".',
443
+ " Non-code (markdown, config, etc.): use section headings, key names, or distinctive lines.",
444
+ ' e.g. "## API Reference" in .md or "[dependencies]" in .toml files.',
319
445
  ].join("\n"),
320
446
  promptSnippet: "Edits a file using exact string replacement, with anchor support and overwrite mode.",
321
447
  promptGuidelines: [
@@ -341,6 +467,8 @@ export function setupIO(pi: ExtensionAPI) {
341
467
  for (const filePath of [...result.modified, ...result.created]) {
342
468
  const absPath = resolveAbsolutePath(cwd, filePath);
343
469
  recordReadTime(absPath);
470
+ const marker = createFileTimeMarkerData(cwd, absPath);
471
+ if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
344
472
  }
345
473
 
346
474
  const summary = formatPatchResult(result);
@@ -361,16 +489,34 @@ export function setupIO(pi: ExtensionAPI) {
361
489
  component.previewArgsKey = argsKey;
362
490
  component.previewPending = false;
363
491
  component.settledError = false;
492
+ component.overwriteHighlightCache = undefined;
364
493
  }
365
494
 
366
- if (context.argsComplete && !component.preview && !component.previewPending) {
367
- component.previewPending = true;
368
- void computePatchPreview(args, context.cwd).then((preview: any) => {
369
- component.preview = preview;
370
- context.invalidate();
371
- });
495
+ // Overwrite streaming: incrementally highlight as new_str streams in
496
+ if (args?.overwrite && typeof args?.path === "string" && typeof args?.new_str === "string") {
497
+ if (context.argsComplete) {
498
+ // Final: full rebuild for best quality
499
+ component.overwriteHighlightCache = rebuildOverwriteHighlightCache(args.path, args.new_str);
500
+ } else {
501
+ // Streaming: incremental update
502
+ component.overwriteHighlightCache = updateOverwriteHighlightCache(
503
+ component.overwriteHighlightCache, args.path, args.new_str,
504
+ );
505
+ }
506
+ // Inject highlighted content as synthetic preview for buildPatchCallComponent
507
+ const cache = component.overwriteHighlightCache;
508
+ if (cache) {
509
+ component.preview = { preview: cache.rawContent, isOverwrite: true };
510
+ } else {
511
+ // No language detected: fall back to plain text
512
+ component.preview = { preview: args.new_str, isOverwrite: true };
513
+ }
372
514
  }
373
515
 
516
+ // Preview diff is computed during execute and delivered via result.details.diff.
517
+ // Skipping async preview here avoids a redundant file read — same design as
518
+ // Pi's native edit tool where renderResult overwrites the preview with execute's diff.
519
+
374
520
  return buildPatchCallComponent(component, args, theme, context.expanded);
375
521
  },
376
522
 
@@ -379,10 +525,42 @@ export function setupIO(pi: ExtensionAPI) {
379
525
  let changed = false;
380
526
 
381
527
  if (callComponent) {
528
+ // overwrite: 保留完整文件预览,不要被空 diff 覆盖成只剩 header
529
+ const overwriteContent = !context.isError && context.args?.overwrite && typeof context.args?.new_str === "string"
530
+ ? context.args.new_str
531
+ : undefined;
532
+
533
+ if (typeof overwriteContent === "string") {
534
+ const nextCache = rebuildOverwriteHighlightCache(context.args?.path, overwriteContent);
535
+ const prevContent = (callComponent.overwriteHighlightCache?.rawContent ?? undefined);
536
+ const prevPath = callComponent.overwriteHighlightCache?.rawPath;
537
+ callComponent.overwriteHighlightCache = nextCache;
538
+ if (
539
+ !(callComponent.preview && "isOverwrite" in callComponent.preview && callComponent.preview.isOverwrite && callComponent.preview.preview === overwriteContent) ||
540
+ prevContent !== overwriteContent ||
541
+ prevPath !== context.args?.path
542
+ ) {
543
+ callComponent.preview = { preview: overwriteContent, isOverwrite: true };
544
+ changed = true;
545
+ }
546
+ } else {
547
+ // 非 overwrite:优先使用 execute 返回的 diff(与 Pi 原生 edit 工具一致)
548
+ const resultDiff = !context.isError && result.details?.diff;
549
+ if (typeof resultDiff === "string") {
550
+ const newPreview = { diff: resultDiff };
551
+ if (callComponent.preview?.diff !== resultDiff) {
552
+ callComponent.preview = newPreview;
553
+ changed = true;
554
+ }
555
+ }
556
+ }
557
+
558
+ // 更新错误状态
382
559
  if (callComponent.settledError !== context.isError) {
383
560
  callComponent.settledError = context.isError;
384
561
  changed = true;
385
562
  }
563
+
386
564
  if (changed) {
387
565
  buildPatchCallComponent(callComponent, context.args, theme, options.expanded);
388
566
  if (context.isError) {
@@ -398,6 +576,9 @@ export function setupIO(pi: ExtensionAPI) {
398
576
  }
399
577
  }
400
578
 
579
+ // Return empty Container — the Box (callComponent) already holds all content.
580
+ // Per pitfall #4: returning the Box would cause ToolExecutionComponent to add
581
+ // it twice to the container, producing duplicate rendering.
401
582
  const component = context.lastComponent ?? new Container();
402
583
  component.clear();
403
584
  return component;