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 +58 -34
- package/extensions/file-times.ts +60 -2
- package/extensions/guidance.ts +5 -3
- package/extensions/index.ts +2 -0
- package/extensions/io.ts +210 -29
- package/extensions/lsp/client.ts +181 -428
- package/extensions/lsp/env.ts +45 -12
- package/extensions/lsp/format.ts +102 -237
- package/extensions/lsp/index.ts +8 -11
- package/extensions/lsp/manager.ts +249 -0
- package/extensions/lsp/prompt.ts +3 -42
- package/extensions/lsp/protocol.ts +219 -0
- package/extensions/lsp/servers.ts +80 -160
- package/extensions/lsp/tools.ts +160 -553
- package/extensions/lsp/types.ts +42 -0
- package/extensions/mcp/builtin.ts +126 -0
- package/extensions/mcp/client.ts +106 -0
- package/extensions/mcp/index.ts +123 -0
- package/extensions/patch.ts +291 -73
- package/extensions/providers/ark-coding.ts +2 -0
- package/extensions/safety/detect.ts +20 -744
- package/extensions/safety/entropy.ts +226 -0
- package/extensions/safety/index.ts +1 -93
- package/extensions/safety/patterns.ts +155 -0
- package/extensions/safety/types.ts +50 -0
- package/extensions/settings.ts +8 -0
- package/extensions/slash.ts +161 -7
- package/extensions/smart-at.ts +5 -5
- package/extensions/subdir-agents.ts +43 -13
- package/package.json +2 -3
- package/tsconfig.json +16 -0
- package/extensions/lsp/server-manager.ts +0 -309
- package/extensions/lsp/trust.ts +0 -45
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# decorated-pi
|
|
2
2
|
|
|
3
|
-
`decorated-pi` is a
|
|
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.
|
|
23
|
+
### 2. Secret redaction
|
|
24
24
|
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
###
|
|
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
|
|
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
|
```
|
package/extensions/file-times.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/extensions/guidance.ts
CHANGED
|
@@ -9,9 +9,11 @@ export function setupGuidance(pi: ExtensionAPI) {
|
|
|
9
9
|
const guidance = [
|
|
10
10
|
DECORATED_PI_GUIDANCE_MARKER,
|
|
11
11
|
"",
|
|
12
|
-
"Before
|
|
13
|
-
"",
|
|
14
|
-
"
|
|
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 {
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
'
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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;
|