decorated-pi 0.2.2 → 0.3.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 +44 -60
- package/extensions/file-times.ts +66 -0
- package/extensions/index.ts +4 -2
- package/extensions/io.ts +406 -0
- package/extensions/lsp/tools.ts +59 -1
- package/extensions/{extend-model.ts → model-integration.ts} +127 -4
- package/extensions/patch.ts +624 -0
- package/extensions/safety/detect.ts +170 -75
- package/extensions/safety/index.ts +54 -15
- package/extensions/settings.ts +2 -0
- package/extensions/slash.ts +6 -4
- package/extensions/smart-at.ts +339 -111
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,65 +1,43 @@
|
|
|
1
1
|
# decorated-pi
|
|
2
2
|
|
|
3
|
-
`decorated-pi` is a Pi extension that adds 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 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.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:decorated-pi
|
|
9
|
+
pi install git:github.com/lcwecker/decorated-pi
|
|
10
|
+
pi install /path/to/decorated-pi
|
|
11
|
+
```
|
|
4
12
|
|
|
5
13
|
## Features
|
|
6
14
|
|
|
7
|
-
### 1.
|
|
15
|
+
### 1. Patch Tool
|
|
8
16
|
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- `git reset/restore/clean/push/revert`
|
|
15
|
-
- `npm publish`
|
|
16
|
-
- `>` / `1>` / `2>` / `&>` / `tee` overwrite existing files
|
|
17
|
-
- Hints the agent to use `edit` instead of `write` on non-empty files
|
|
18
|
-
- **Protected paths**
|
|
19
|
-
- 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.
|
|
20
|
-
- **Secret redaction**
|
|
21
|
-
- Dual-layer detection: 40+ known-format patterns (AWS, GitHub, OpenAI, etc.) + Adjusted Shannon Entropy analysis for unknown formats. Based on [opencode-secrets-protect](https://github.com/jscheel/opencode-secrets-protect) (MIT)
|
|
17
|
+
Replaces Pi's built-in `edit` / `write` with a stronger `patch` tool:
|
|
18
|
+
|
|
19
|
+
- **anchor mechanism** — narrows the search range by specifying a unique string that appears before `old_str`, preventing mismatches in files with repeated patterns
|
|
20
|
+
- **mtime tracking** — records file modification time on `read`, rejects `patch` if the file changed since last read, preventing blind or stale edits
|
|
21
|
+
- **explicit overwrite** — offer atomic `overwrite: true` mode for overwrite files or full-file creation to prevent unintened overwrite
|
|
22
22
|
|
|
23
23
|
### 2. Smart `@` File Search
|
|
24
24
|
|
|
25
|
-
Replaces Pi's default file search with a faster project-aware search strategy:
|
|
25
|
+
Replaces Pi's default file search with a faster, project-aware search strategy:
|
|
26
26
|
|
|
27
|
-
- Uses
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
- Penalizes hidden/cache/build directories
|
|
32
|
-
- Hides hidden paths from empty-query results
|
|
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
|
|
33
31
|
|
|
34
32
|
### 3. LSP Tool Suite
|
|
35
33
|
|
|
36
|
-
Based on [@spences10/pi-lsp](https://github.com/spences10/my-pi/tree/main/packages/pi-lsp)
|
|
34
|
+
Based on [@spences10/pi-lsp](https://github.com/spences10/my-pi/tree/main/packages/pi-lsp), with major additions:
|
|
37
35
|
|
|
38
|
-
- C/C++
|
|
36
|
+
- C/C++ and Lua support
|
|
39
37
|
- `lsp_find_symbol`, `lsp_rename`, multi-file support merged into `lsp_diagnostics`
|
|
40
38
|
- Force-sync on `didChange` (no stale diagnostics)
|
|
41
39
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- `lsp_diagnostics`
|
|
45
|
-
- `lsp_find_symbol`
|
|
46
|
-
- `lsp_hover`
|
|
47
|
-
- `lsp_definition`
|
|
48
|
-
- `lsp_references`
|
|
49
|
-
- `lsp_document_symbols`
|
|
50
|
-
- `lsp_rename`
|
|
51
|
-
|
|
52
|
-
Supported languages:
|
|
53
|
-
|
|
54
|
-
- c/cpp
|
|
55
|
-
- go
|
|
56
|
-
- java
|
|
57
|
-
- lua
|
|
58
|
-
- python
|
|
59
|
-
- ruby
|
|
60
|
-
- rust
|
|
61
|
-
- svelte
|
|
62
|
-
- typescript
|
|
40
|
+
Supported languages: c/cpp, go, java, lua, python, ruby, rust, svelte, typescript
|
|
63
41
|
|
|
64
42
|
### 4. Auxiliary Models (Image + Compact)
|
|
65
43
|
|
|
@@ -68,15 +46,31 @@ Uses cheaper models for auxiliary tasks, configured via `/dp-model`:
|
|
|
68
46
|
- **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)
|
|
69
47
|
- **Compact model** — uses a configured model for context compaction (instead of the main model), auto-resumes after compaction.
|
|
70
48
|
|
|
71
|
-
### 5.
|
|
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`
|
|
72
67
|
|
|
73
68
|
When the agent reads or edits a file:
|
|
74
69
|
|
|
75
70
|
- discovers `AGENTS.md` / `CLAUDE.md` in the file's directory and ancestor directories
|
|
76
71
|
- injects newly discovered guidance into tool results
|
|
77
|
-
- persists discovered files into the session so they are restored on resume
|
|
78
72
|
|
|
79
|
-
###
|
|
73
|
+
### 7. Extend Providers
|
|
80
74
|
|
|
81
75
|
Extend providers are registered via `/login` → "Use a subscription":
|
|
82
76
|
|
|
@@ -86,16 +80,6 @@ Extend providers are registered via `/login` → "Use a subscription":
|
|
|
86
80
|
| Baidu Qianfan | `qianfan.baidubce.com/v2/coding` |
|
|
87
81
|
| ARK Coding | `ark.cn-beijing.volces.com/api/coding/v3` |
|
|
88
82
|
|
|
89
|
-
## Install
|
|
90
|
-
|
|
91
|
-
```bash
|
|
92
|
-
pi install /path/to/decorated-pi #local
|
|
93
|
-
pi install npm:decorated-pi #npm
|
|
94
|
-
pi install git:github.com/lcwecker/decorated-pi #github
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Then reload Pi
|
|
98
|
-
|
|
99
83
|
## Configuration
|
|
100
84
|
|
|
101
85
|
Runtime settings are stored in:
|
|
@@ -110,6 +94,7 @@ Modules can be toggled on/off. Changes take effect after `/reload`.
|
|
|
110
94
|
|
|
111
95
|
| Module | Default | Effect when disabled |
|
|
112
96
|
| -------- | --------- | --------------------- |
|
|
97
|
+
| `patch` | `true` | Reverts to Pi's built-in `edit` / `write` tools |
|
|
113
98
|
| `safety` | `true` | No command guard, no protected path check, no secret redaction |
|
|
114
99
|
| `lsp` | `true` | All `lsp_*` tools unregistered — no diagnostics, hover, etc. |
|
|
115
100
|
| `smart-at` | `true` | Fallback to Pi's built-in `@` file completion |
|
|
@@ -119,6 +104,7 @@ Use `/dp-settings` to toggle, or edit the config file directly:
|
|
|
119
104
|
```json
|
|
120
105
|
{
|
|
121
106
|
"modules": {
|
|
107
|
+
"patch": true,
|
|
122
108
|
"safety": true,
|
|
123
109
|
"lsp": false,
|
|
124
110
|
"smart-at": true
|
|
@@ -126,8 +112,6 @@ Use `/dp-settings` to toggle, or edit the config file directly:
|
|
|
126
112
|
}
|
|
127
113
|
```
|
|
128
114
|
|
|
129
|
-
Omitted keys default to `true` (enabled).
|
|
130
|
-
|
|
131
115
|
## License
|
|
132
116
|
|
|
133
117
|
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File mtime tracking for stale-read protection.
|
|
3
|
+
*
|
|
4
|
+
* - `read` tool records mtime when the LLM reads a file
|
|
5
|
+
* - `patch` tool checks mtime before editing — rejects if file changed since last read
|
|
6
|
+
* - `patch` tool updates mtime after a successful write
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Last-known mtime for each absolute file path (ms since epoch). */
|
|
13
|
+
const readMarkers = new Map<string, number>();
|
|
14
|
+
|
|
15
|
+
/** Get current file mtime in ms. Throws if file doesn't exist. */
|
|
16
|
+
function getFileMtime(absPath: string): number {
|
|
17
|
+
const stat = fs.statSync(absPath);
|
|
18
|
+
return stat.mtimeMs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Record that the LLM has seen the current version of a file.
|
|
22
|
+
* Called after `read` tool completes and after `patch` writes. */
|
|
23
|
+
export function recordReadTime(absPath: string): void {
|
|
24
|
+
if (!fs.existsSync(absPath)) return;
|
|
25
|
+
readMarkers.set(absPath, getFileMtime(absPath));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Check if file has been modified since last read.
|
|
29
|
+
* Returns an error message if stale, or undefined if ok to edit. */
|
|
30
|
+
export function checkStaleFile(absPath: string, displayPath: string): string | undefined {
|
|
31
|
+
// If file doesn't exist on disk, always allow — creating a new file
|
|
32
|
+
// doesn't require reading it first, and recreating a deleted file is safe.
|
|
33
|
+
if (!fs.existsSync(absPath)) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lastRead = readMarkers.get(absPath);
|
|
38
|
+
if (lastRead === undefined) {
|
|
39
|
+
// File exists but never read — must read first to avoid blind edits
|
|
40
|
+
return (
|
|
41
|
+
`File not read yet: ${displayPath}. ` +
|
|
42
|
+
`Please read the file with the read tool before editing.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const currentMtime = getFileMtime(absPath);
|
|
47
|
+
if (currentMtime > lastRead) {
|
|
48
|
+
return (
|
|
49
|
+
`File modified since last read: ${displayPath}. ` +
|
|
50
|
+
`Please re-read the file with the read tool before editing.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Clear all tracked file times (e.g., on session start). */
|
|
58
|
+
export function clearReadMarkers(): void {
|
|
59
|
+
readMarkers.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve a relative path to absolute (for consistent map keys). */
|
|
63
|
+
export function resolveAbsolutePath(cwd: string, filePath: string): string {
|
|
64
|
+
if (path.isAbsolute(filePath)) return path.normalize(filePath);
|
|
65
|
+
return path.normalize(path.resolve(cwd, filePath));
|
|
66
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { setupSafety } from "./safety/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { setupModelIntegration } from "./model-integration";
|
|
8
8
|
import { setupSlash } from "./slash";
|
|
9
9
|
import { setupSubdirAgents } from "./subdir-agents";
|
|
10
10
|
import { setupSessionTitle } from "./session-title";
|
|
11
|
+
import { setupIO } from "./io";
|
|
11
12
|
import { setupGuidance } from "./guidance";
|
|
12
13
|
import { setupLsp } from "./lsp/index";
|
|
13
14
|
import { setupProviders } from "./providers/index";
|
|
@@ -18,12 +19,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
18
19
|
// Always loaded — core commands and providers
|
|
19
20
|
setupSlash(pi);
|
|
20
21
|
setupProviders(pi);
|
|
21
|
-
|
|
22
|
+
setupModelIntegration(pi);
|
|
22
23
|
setupSubdirAgents(pi);
|
|
23
24
|
setupSessionTitle(pi);
|
|
24
25
|
setupGuidance(pi);
|
|
25
26
|
|
|
26
27
|
// Configurable modules
|
|
28
|
+
if (isModuleEnabled("patch")) setupIO(pi);
|
|
27
29
|
if (isModuleEnabled("safety")) setupSafety(pi);
|
|
28
30
|
if (isModuleEnabled("lsp")) setupLsp(pi);
|
|
29
31
|
if (isModuleEnabled("smart-at")) setupSmartAt(pi);
|
package/extensions/io.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IO — Replace Pi native edit/write with `patch` tool (single-file)
|
|
3
|
+
*
|
|
4
|
+
* Keeps: read (for stale-read protection via mtime tracking)
|
|
5
|
+
* Removes: edit, write
|
|
6
|
+
* Adds: patch (old_str/new_str exact replacement, single file per call)
|
|
7
|
+
*
|
|
8
|
+
* Schema: { path, edits?, overwrite?, new_str? }
|
|
9
|
+
* Pi’s native parallel tool calls handle multi-file scenarios.
|
|
10
|
+
*
|
|
11
|
+
* Stale-read protection:
|
|
12
|
+
* - `read` tool records file mtime when LLM reads a file
|
|
13
|
+
* - `patch` tool checks: if file mtime > last-read mtime → reject
|
|
14
|
+
* - `patch` tool updates mtime after successful write
|
|
15
|
+
*
|
|
16
|
+
* TUI Rendering Pitfalls (learned the hard way):
|
|
17
|
+
* 1. execute() MUST throw errors, NOT return { isError: true }
|
|
18
|
+
* 2. TUI rendering MUST mirror the edit tool pattern exactly
|
|
19
|
+
* 3. getPatchHeaderBg: settledError MUST be checked first
|
|
20
|
+
* 4. renderResult must NOT return the Box
|
|
21
|
+
* 5. Error text must go INSIDE the Box, not in the result Container
|
|
22
|
+
* 6. prepareArguments must handle literal newlines in JSON strings
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { defineTool, isReadToolResult, keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { renderDiff } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
28
|
+
import { Type } from "typebox";
|
|
29
|
+
import {
|
|
30
|
+
applyPatch,
|
|
31
|
+
formatPatchResult,
|
|
32
|
+
generatePatchDiff,
|
|
33
|
+
computePatchPreview,
|
|
34
|
+
type PatchPreview,
|
|
35
|
+
} from "./patch.js";
|
|
36
|
+
import { recordReadTime, checkStaleFile, clearReadMarkers, resolveAbsolutePath } from "./file-times.js";
|
|
37
|
+
|
|
38
|
+
// ─── Schema ─────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const EditSchema = Type.Object({
|
|
41
|
+
anchor: Type.Optional(Type.String({
|
|
42
|
+
description: "Optional unique string that appears BEFORE old_str in the file. Narrows the search range.",
|
|
43
|
+
})),
|
|
44
|
+
old_str: Type.String({
|
|
45
|
+
description: "Exact text to find. Must be unique within the search range. String, not regex.",
|
|
46
|
+
}),
|
|
47
|
+
new_str: Type.String({
|
|
48
|
+
description: "Replacement text. String. Use empty string to delete.",
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const PatchSchema = Type.Object({
|
|
53
|
+
path: Type.String({
|
|
54
|
+
description: "Path to the file to edit (relative or absolute).",
|
|
55
|
+
}),
|
|
56
|
+
edits: Type.Optional(Type.Array(EditSchema, {
|
|
57
|
+
description: "Targeted replacements applied sequentially.",
|
|
58
|
+
})),
|
|
59
|
+
overwrite: Type.Optional(Type.Boolean({
|
|
60
|
+
description: "If true, replace the entire file atomically (write temp → mv).",
|
|
61
|
+
})),
|
|
62
|
+
new_str: Type.Optional(Type.String({
|
|
63
|
+
description: "Entire new file content when overwrite is true.",
|
|
64
|
+
})),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── Argument repair ───────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function fixJsonNewlines(str: string): string {
|
|
70
|
+
let result = '';
|
|
71
|
+
let inString = false;
|
|
72
|
+
let escaped = false;
|
|
73
|
+
for (let i = 0; i < str.length; i++) {
|
|
74
|
+
const ch = str[i];
|
|
75
|
+
if (escaped) { result += ch; escaped = false; continue; }
|
|
76
|
+
if (ch === '\\') { result += ch; escaped = true; continue; }
|
|
77
|
+
if (ch === '"') { inString = !inString; result += ch; continue; }
|
|
78
|
+
if (inString && (ch === '\n' || ch === '\r')) {
|
|
79
|
+
result += ch === '\n' ? '\\n' : '\\r';
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
result += ch;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function jsonParseWithNewlineFix(str: string): any {
|
|
88
|
+
try { return JSON.parse(str); }
|
|
89
|
+
catch { try { return JSON.parse(fixJsonNewlines(str)); } catch { return undefined; } }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function preparePatchArguments(input: any): any {
|
|
93
|
+
if (!input || typeof input !== "object") return input;
|
|
94
|
+
|
|
95
|
+
const args = input as Record<string, any>;
|
|
96
|
+
|
|
97
|
+
// Legacy multi-file format: { patches: [{ path, edits }] } → extract first
|
|
98
|
+
if (Array.isArray(args.patches) && !args.path) {
|
|
99
|
+
const first = args.patches[0];
|
|
100
|
+
if (first && typeof first === "object" && first.path) {
|
|
101
|
+
Object.assign(args, first);
|
|
102
|
+
delete args.patches;
|
|
103
|
+
}
|
|
104
|
+
} else if (typeof args.patches === "string" && !args.path) {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = jsonParseWithNewlineFix(args.patches);
|
|
107
|
+
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0]?.path) {
|
|
108
|
+
Object.assign(args, parsed[0]);
|
|
109
|
+
delete args.patches;
|
|
110
|
+
} else if (parsed && typeof parsed === "object" && parsed.path) {
|
|
111
|
+
Object.assign(args, parsed);
|
|
112
|
+
delete args.patches;
|
|
113
|
+
}
|
|
114
|
+
} catch { /* keep original */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Edits serialized as JSON string
|
|
118
|
+
if (typeof args.edits === "string") {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = jsonParseWithNewlineFix(args.edits);
|
|
121
|
+
if (Array.isArray(parsed)) args.edits = parsed;
|
|
122
|
+
} catch { /* keep original */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Legacy: top-level old_str/new_str instead of edits array
|
|
126
|
+
if (typeof args.old_str === "string" && typeof args.new_str === "string") {
|
|
127
|
+
const edit: any = { old_str: args.old_str, new_str: args.new_str };
|
|
128
|
+
if (typeof args.anchor === "string") edit.anchor = args.anchor;
|
|
129
|
+
args.edits = args.edits ? [...args.edits, edit] : [edit];
|
|
130
|
+
delete args.old_str;
|
|
131
|
+
delete args.new_str;
|
|
132
|
+
delete args.anchor;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return args;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── TUI rendering ─────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
interface PatchCallComponent extends Box {
|
|
141
|
+
preview?: PatchPreview;
|
|
142
|
+
previewArgsKey?: string;
|
|
143
|
+
previewPending?: boolean;
|
|
144
|
+
settledError: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createPatchCallComponent(): PatchCallComponent {
|
|
148
|
+
return Object.assign(new Box(1, 1, (text: string) => text), {
|
|
149
|
+
preview: undefined,
|
|
150
|
+
previewArgsKey: undefined,
|
|
151
|
+
previewPending: false,
|
|
152
|
+
settledError: false,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getPatchCallComponent(state: any, lastComponent: any): PatchCallComponent {
|
|
157
|
+
if (lastComponent instanceof Box) {
|
|
158
|
+
const comp = lastComponent as PatchCallComponent;
|
|
159
|
+
state.callComponent = comp;
|
|
160
|
+
return comp;
|
|
161
|
+
}
|
|
162
|
+
if (state.callComponent) return state.callComponent;
|
|
163
|
+
const comp = createPatchCallComponent();
|
|
164
|
+
state.callComponent = comp;
|
|
165
|
+
return comp;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
|
|
169
|
+
if (component.settledError) {
|
|
170
|
+
return (text: string) => theme.bg("toolErrorBg", text);
|
|
171
|
+
}
|
|
172
|
+
if (component.preview) {
|
|
173
|
+
if ("error" in component.preview && component.preview.error) {
|
|
174
|
+
return (text: string) => theme.bg("toolErrorBg", text);
|
|
175
|
+
}
|
|
176
|
+
return (text: string) => theme.bg("toolSuccessBg", text);
|
|
177
|
+
}
|
|
178
|
+
return (text: string) => theme.bg("toolPendingBg", text);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function createSingleLineComponent(text: string) {
|
|
182
|
+
return {
|
|
183
|
+
render(width: number) {
|
|
184
|
+
return [truncateToWidth(text, width)];
|
|
185
|
+
},
|
|
186
|
+
invalidate() {},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function appendPatchDiffChildren(parent: Box, body: string, theme: any): void {
|
|
191
|
+
const rawLines = body.split("\n");
|
|
192
|
+
let buffer: string[] = [];
|
|
193
|
+
|
|
194
|
+
const flush = () => {
|
|
195
|
+
if (buffer.length === 0) return;
|
|
196
|
+
parent.addChild(new Text(renderDiff(buffer.join("\n")), 0, 0));
|
|
197
|
+
buffer = [];
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
for (const line of rawLines) {
|
|
201
|
+
if (line.startsWith("@@ lines ")) {
|
|
202
|
+
flush();
|
|
203
|
+
parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (line === "anchors:") {
|
|
207
|
+
flush();
|
|
208
|
+
parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (line.startsWith(" - ")) {
|
|
212
|
+
flush();
|
|
213
|
+
parent.addChild(createSingleLineComponent(theme.fg("accent", line)) as any);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
buffer.push(line);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
flush();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildPatchCallComponent(component: PatchCallComponent, args: any, theme: any, expanded = false) {
|
|
223
|
+
component.setBgFn(getPatchHeaderBg(component, theme));
|
|
224
|
+
component.clear();
|
|
225
|
+
|
|
226
|
+
let label = "";
|
|
227
|
+
if (args?.path) {
|
|
228
|
+
label = theme.fg("accent", args.path);
|
|
229
|
+
if (args.overwrite) {
|
|
230
|
+
label += theme.fg("warning", " [overwrite]");
|
|
231
|
+
} else if (args.edits?.length > 0) {
|
|
232
|
+
label += theme.fg("dim", ` (${args.edits.length} edit${args.edits.length > 1 ? "s" : ""})`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const headerText = theme.fg("toolTitle", theme.bold("patch")) + (label ? " " + label : "");
|
|
236
|
+
component.addChild(new Text(headerText, 0, 0));
|
|
237
|
+
|
|
238
|
+
if (component.settledError || !component.preview) return component;
|
|
239
|
+
|
|
240
|
+
const preview = component.preview;
|
|
241
|
+
let body = "";
|
|
242
|
+
if ("isOverwrite" in preview && preview.isOverwrite && preview.preview) {
|
|
243
|
+
body = preview.preview;
|
|
244
|
+
} else if ("diff" in preview && preview.diff) {
|
|
245
|
+
body = preview.diff;
|
|
246
|
+
} else if ("error" in preview && preview.error) {
|
|
247
|
+
component.addChild(new Spacer(1));
|
|
248
|
+
component.addChild(new Text(theme.fg("error", ` Error: ${preview.error}`), 0, 0));
|
|
249
|
+
return component;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!body) return component;
|
|
253
|
+
|
|
254
|
+
const lines = body.split("\n");
|
|
255
|
+
const FOLD_THRESHOLD = 45;
|
|
256
|
+
|
|
257
|
+
component.addChild(new Spacer(1));
|
|
258
|
+
if (lines.length > FOLD_THRESHOLD && !expanded) {
|
|
259
|
+
// 折叠态: 显示前几行 + 摘要
|
|
260
|
+
const shown = lines.slice(0, 10).join("\n");
|
|
261
|
+
const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
|
|
262
|
+
if (isOverwrite) {
|
|
263
|
+
component.addChild(new Text(theme.fg("toolDiffAdded", shown), 0, 0));
|
|
264
|
+
} else {
|
|
265
|
+
appendPatchDiffChildren(component, shown, theme);
|
|
266
|
+
}
|
|
267
|
+
component.addChild(new Text(
|
|
268
|
+
theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + keyHint("app.tools.expand", "expand") + theme.fg("dim", ")"),
|
|
269
|
+
0, 0,
|
|
270
|
+
));
|
|
271
|
+
} else {
|
|
272
|
+
// 展开态: 完整显示
|
|
273
|
+
const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
|
|
274
|
+
if (isOverwrite) {
|
|
275
|
+
component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
|
|
276
|
+
} else {
|
|
277
|
+
appendPatchDiffChildren(component, body, theme);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return component;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Setup ──────────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
export function setupIO(pi: ExtensionAPI) {
|
|
287
|
+
pi.on("session_start", () => {
|
|
288
|
+
clearReadMarkers();
|
|
289
|
+
const active = pi.getActiveTools();
|
|
290
|
+
pi.setActiveTools(active.filter(t => !["edit", "write"].includes(t)));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Track file read times
|
|
294
|
+
pi.on("tool_result", (event, ctx) => {
|
|
295
|
+
if (!isReadToolResult(event)) return;
|
|
296
|
+
const filePath = event.input?.path;
|
|
297
|
+
if (typeof filePath !== "string" || !filePath.trim()) return;
|
|
298
|
+
const cwd: string = ctx.cwd ?? process.cwd();
|
|
299
|
+
const absPath = resolveAbsolutePath(cwd, filePath);
|
|
300
|
+
recordReadTime(absPath);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
pi.registerTool(defineTool({
|
|
304
|
+
name: "patch",
|
|
305
|
+
label: "Patch",
|
|
306
|
+
description: [
|
|
307
|
+
"Edits a file using exact string replacement, with optional anchor to narrow search.",
|
|
308
|
+
"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
|
+
"",
|
|
311
|
+
"Examples:",
|
|
312
|
+
' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return y" }] }',
|
|
313
|
+
' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
|
|
314
|
+
' { 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.',
|
|
319
|
+
].join("\n"),
|
|
320
|
+
promptSnippet: "Edits a file using exact string replacement, with anchor support and overwrite mode.",
|
|
321
|
+
promptGuidelines: [
|
|
322
|
+
"Always prefer modifying files with PATCH tool over bash commands or python scripts.",
|
|
323
|
+
"For full-file replacement, always use patch tool to prevent unintended edits or data loss.",
|
|
324
|
+
],
|
|
325
|
+
parameters: PatchSchema,
|
|
326
|
+
renderShell: "self",
|
|
327
|
+
prepareArguments: preparePatchArguments,
|
|
328
|
+
execute: async (_toolCallId: string, input: { path: string; edits?: any[]; overwrite?: boolean; new_str?: string }, _signal: any, _onUpdate: any, ctx: any) => {
|
|
329
|
+
const cwd: string = ctx.cwd ?? process.cwd();
|
|
330
|
+
|
|
331
|
+
// Stale-read protection (only for edits, not overwrite)
|
|
332
|
+
if (!input.overwrite && input.path?.trim()) {
|
|
333
|
+
const absPath = resolveAbsolutePath(cwd, input.path);
|
|
334
|
+
const staleError = checkStaleFile(absPath, input.path);
|
|
335
|
+
if (staleError) throw new Error(staleError);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const result = await applyPatch(input as any, cwd);
|
|
339
|
+
|
|
340
|
+
// Update read markers after successful write
|
|
341
|
+
for (const filePath of [...result.modified, ...result.created]) {
|
|
342
|
+
const absPath = resolveAbsolutePath(cwd, filePath);
|
|
343
|
+
recordReadTime(absPath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const summary = formatPatchResult(result);
|
|
347
|
+
const diff = generatePatchDiff(result);
|
|
348
|
+
return {
|
|
349
|
+
content: [{ type: "text", text: summary }],
|
|
350
|
+
details: { diff },
|
|
351
|
+
};
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
renderCall(args: any, theme: any, context: any) {
|
|
355
|
+
const state = context.state;
|
|
356
|
+
const component = getPatchCallComponent(state, context.lastComponent);
|
|
357
|
+
|
|
358
|
+
const argsKey = args ? JSON.stringify(args) : undefined;
|
|
359
|
+
if (component.previewArgsKey !== argsKey) {
|
|
360
|
+
component.preview = undefined;
|
|
361
|
+
component.previewArgsKey = argsKey;
|
|
362
|
+
component.previewPending = false;
|
|
363
|
+
component.settledError = false;
|
|
364
|
+
}
|
|
365
|
+
|
|
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
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return buildPatchCallComponent(component, args, theme, context.expanded);
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
renderResult(result: any, options: any, theme: any, context: any) {
|
|
378
|
+
const callComponent: PatchCallComponent | undefined = context.state.callComponent;
|
|
379
|
+
let changed = false;
|
|
380
|
+
|
|
381
|
+
if (callComponent) {
|
|
382
|
+
if (callComponent.settledError !== context.isError) {
|
|
383
|
+
callComponent.settledError = context.isError;
|
|
384
|
+
changed = true;
|
|
385
|
+
}
|
|
386
|
+
if (changed) {
|
|
387
|
+
buildPatchCallComponent(callComponent, context.args, theme, options.expanded);
|
|
388
|
+
if (context.isError) {
|
|
389
|
+
const errorText = result.content
|
|
390
|
+
.filter((c: any) => c.type === "text")
|
|
391
|
+
.map((c: any) => c.text || "")
|
|
392
|
+
.join("\n");
|
|
393
|
+
if (errorText) {
|
|
394
|
+
callComponent.addChild(new Spacer(1));
|
|
395
|
+
callComponent.addChild(new Text(theme.fg("error", errorText), 0, 0));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const component = context.lastComponent ?? new Container();
|
|
402
|
+
component.clear();
|
|
403
|
+
return component;
|
|
404
|
+
},
|
|
405
|
+
}));
|
|
406
|
+
}
|