pi-vscode-sr 1.4.1 → 1.4.3

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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +437 -0
package/README.md CHANGED
@@ -24,6 +24,12 @@ You can also approve/reject from the diff tab.
24
24
 
25
25
  ## 📦 Installation
26
26
 
27
+ > [!IMPORTANT]
28
+ > Add into `.gitignore` file
29
+ > - .pi/review-requests/
30
+ > - .pi/review-results/
31
+ > - .pi/tmp/
32
+
27
33
  ### 1. Pi Extension
28
34
 
29
35
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vscode-sr",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Code diff assistant for VS Code.",
5
5
  "license": "MIT",
6
6
  "author": "Serhioromano",
package/src/index.ts ADDED
@@ -0,0 +1,437 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { randomUUID } from "crypto";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
6
+ import { dirname, join, resolve } from "path";
7
+
8
+ // ─── Session state ───────────────────────────────────────────────────
9
+
10
+ const sessionReviewIds = new Set<string>();
11
+ const sessionApproveAll = new Set<string>(); // review IDs auto-approved
12
+ let projectCwd: string | null = null;
13
+
14
+
15
+ // ─── Helper: create review request and wait for result ───────────────
16
+
17
+ type ReviewOutcome =
18
+ | { status: "approved"; final: string }
19
+ | { status: "rejected" }
20
+ | { status: "rethink"; prompt: string }
21
+ | { status: "timeout" };
22
+
23
+ async function createReviewAndWait(
24
+ ctx: ExtensionContext,
25
+ filePath: string,
26
+ original: string,
27
+ proposed: string,
28
+ description: string,
29
+ ): Promise<ReviewOutcome> {
30
+ // Normalize path: LLM may pass absolute-looking path without leading /
31
+ const normalizedPath = resolveSafe(ctx.cwd, filePath);
32
+ const uuid = randomUUID();
33
+ const requestsDir = join(ctx.cwd, ".pi", "review-requests");
34
+ const resultsDir = join(ctx.cwd, ".pi", "review-results");
35
+ mkdirSync(requestsDir, { recursive: true });
36
+ mkdirSync(resultsDir, { recursive: true });
37
+
38
+ const reviewRequest = {
39
+ id: uuid,
40
+ title: description,
41
+ files: [{ path: normalizedPath, original, proposed, description }],
42
+ };
43
+
44
+ writeFileSync(join(requestsDir, `${uuid}.json`), JSON.stringify(reviewRequest, null, 2), "utf-8");
45
+ ctx.ui.notify(`📝 Review: ${filePath} — check VS Code diff`, "info");
46
+
47
+ sessionReviewIds.add(uuid);
48
+
49
+ // Check if approve-all was already chosen
50
+ if (sessionApproveAll.size > 0) {
51
+ writeSyncResult(resultsDir, uuid, "approved", proposed);
52
+ return { status: "approved", final: proposed };
53
+ }
54
+
55
+ // Show TUI immediately and poll VS Code in parallel (every 500ms).
56
+ // AbortController dismisses the TUI if VS Code responds first.
57
+ const resultPath = join(resultsDir, `${uuid}.json`);
58
+ const deadline = Date.now() + 10 * 60 * 1000;
59
+ const tuiController = new AbortController();
60
+ const tuiPromise = showTuiSelector(ctx, filePath, { signal: tuiController.signal });
61
+ const pollPromise = pollResultFile(resultPath, deadline, 500);
62
+
63
+ const outcome = await Promise.race([tuiPromise, pollPromise]);
64
+
65
+ // If poll resolved first (VS Code responded), dismiss the TUI selector.
66
+ // Without this, the TUI stays on screen even after the review is done.
67
+ if (outcome.action === "file-approved" || outcome.action === "file-rejected") {
68
+ tuiController.abort();
69
+ // Wait for TUI to close gracefully (aborted select resolves quickly)
70
+ await tuiPromise.catch(() => { });
71
+ }
72
+
73
+ // ── Process outcome (return result, never throw) ──
74
+
75
+ if (outcome.action === "abort") {
76
+ writeSyncResult(resultsDir, uuid, "rejected");
77
+ ctx.abort();
78
+ return { status: "rejected" };
79
+ }
80
+
81
+ if (outcome.action === "rethink") {
82
+ writeSyncResult(resultsDir, uuid, "rejected");
83
+ return { status: "rethink", prompt: outcome.prompt! };
84
+ }
85
+
86
+ if (outcome.action === "file-rejected") {
87
+ return { status: "rejected" };
88
+ }
89
+ if (outcome.action === "rejected") {
90
+ writeSyncResult(resultsDir, uuid, "rejected");
91
+ return { status: "rejected" };
92
+ }
93
+ if (outcome.action === "file-approved") {
94
+ return { status: "approved", final: proposed };
95
+ }
96
+ if (outcome.action === "approved") {
97
+ writeSyncResult(resultsDir, uuid, "approved", proposed);
98
+ return { status: "approved", final: proposed };
99
+ }
100
+ if (outcome.action === "approve-all") {
101
+ for (const rid of sessionReviewIds) {
102
+ sessionApproveAll.add(rid);
103
+ }
104
+ writeSyncResult(resultsDir, uuid, "approved", proposed);
105
+ return { status: "approved", final: proposed };
106
+ }
107
+
108
+ return { status: "timeout" };
109
+ }
110
+
111
+ function writeSyncResult(resultsDir: string, uuid: string, status: "approved" | "rejected", content?: string) {
112
+ writeFileSync(
113
+ join(resultsDir, `${uuid}.json`),
114
+ JSON.stringify(
115
+ {
116
+ id: uuid,
117
+ files: [{ path: "", status, final: content ?? "" }],
118
+ },
119
+ null,
120
+ 2,
121
+ ),
122
+ "utf-8",
123
+ );
124
+ }
125
+
126
+ async function pollResultFile(resultPath: string, deadline: number, interval = 500): Promise<{ action: string; prompt?: string }> {
127
+ while (Date.now() < deadline) {
128
+ try {
129
+ if (existsSync(resultPath)) {
130
+ const raw = readFileSync(resultPath, "utf-8");
131
+ if (!raw.trim()) {
132
+ // File exists but is empty — still being written, retry next cycle
133
+ await sleep(interval);
134
+ continue;
135
+ }
136
+ const result = JSON.parse(raw);
137
+ const fileResult = result.files?.[0];
138
+ if (result.status === "rejected" || fileResult?.status === "rejected") {
139
+ return { action: "file-rejected" };
140
+ }
141
+ return { action: "file-approved" };
142
+ }
143
+ } catch {
144
+ // File may be partially written or malformed — retry
145
+ }
146
+ await sleep(interval);
147
+ }
148
+ return { action: "timeout" };
149
+ }
150
+
151
+ async function showTuiSelector(
152
+ ctx: ExtensionContext,
153
+ filePath: string,
154
+ opts?: { signal?: AbortSignal },
155
+ ): Promise<{ action: string; prompt?: string }> {
156
+ const choice = await ctx.ui.select(
157
+ `📝 Review: ${filePath}`,
158
+ [
159
+ "✅ Approve",
160
+ "❌ Reject",
161
+ "💭 Rethink",
162
+ "⭐ Approve All for this session",
163
+ "🚪 Abort",
164
+ ],
165
+ opts,
166
+ );
167
+
168
+ if (!choice) return { action: "timeout" };
169
+ if (choice.startsWith("🚪")) return { action: "abort" };
170
+ if (choice.startsWith("⭐")) return { action: "approve-all" };
171
+ if (choice.startsWith("💭")) {
172
+ const prompt = await ctx.ui.input(
173
+ "🔄 Rethink — what should the agent reconsider?",
174
+ "Describe what needs to change...",
175
+ opts,
176
+ );
177
+ if (!prompt || !prompt.trim()) return { action: "rejected" };
178
+ return { action: "rethink", prompt: prompt.trim() };
179
+ }
180
+ if (choice.startsWith("✅")) return { action: "approved" };
181
+ if (choice.startsWith("❌")) return { action: "rejected" };
182
+ return { action: "timeout" };
183
+ }
184
+
185
+ function sleep(ms: number): Promise<void> {
186
+ return new Promise((r) => setTimeout(r, ms));
187
+ }
188
+
189
+ // ─── Apply edits in-memory (mirrors built-in edit logic) ─────────────
190
+
191
+ function applyEdits(content: string, edits: Array<{ oldText: string; newText: string }>): string {
192
+ let result = content;
193
+ for (const edit of edits) {
194
+ const idx = result.indexOf(edit.oldText);
195
+ if (idx === -1) {
196
+ throw new Error(`oldText not found in file`);
197
+ }
198
+ const nextIdx = result.indexOf(edit.oldText, idx + 1);
199
+ if (nextIdx !== -1) {
200
+ throw new Error(`oldText is not unique in file`);
201
+ }
202
+ result = result.replace(edit.oldText, edit.newText);
203
+ }
204
+ return result;
205
+ }
206
+
207
+ // ─── Path normalization ──────────────────────────────────────────────
208
+
209
+ /**
210
+ * Safe path resolution. If the LLM passes an absolute-looking path
211
+ * without a leading slash (e.g. "home/user/project/file.ts"), resolve()
212
+ * treats it as relative and doubles the cwd. Detect and fix this.
213
+ */
214
+ function resolveSafe(cwd: string, filePath: string): string {
215
+ // Strip leading/trailing slashes from cwd for comparison
216
+ const cwdClean = cwd.replace(/\/+$/, "").replace(/^\//, "");
217
+ // If filePath starts with cwdClean/ (LLM forgot the leading /),
218
+ // strip it so resolve doesn't double.
219
+ if (filePath.startsWith(cwdClean + "/")) {
220
+ filePath = filePath.substring(cwdClean.length + 1);
221
+ }
222
+ return resolve(cwd, filePath);
223
+ }
224
+
225
+ // ─── Override `write` ────────────────────────────────────────────────
226
+
227
+ function registerWriteOverride(pi: ExtensionAPI) {
228
+ pi.registerTool({
229
+ name: "write",
230
+ label: "write (with review)",
231
+ description:
232
+ "Write content to a file. Instead of writing directly, creates a review request so the user " +
233
+ "can approve or reject the change in VS Code or directly in the terminal. Returns only after " +
234
+ "the user makes a decision.",
235
+ promptSnippet: "Create or overwrite files with user review",
236
+ promptGuidelines: [
237
+ "Use write for any file creation or complete rewrite — user review is required.",
238
+ "The tool blocks until the user approves or rejects.",
239
+ "If content is identical to existing file, no review is created.",
240
+ ],
241
+ parameters: Type.Object({
242
+ path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
243
+ content: Type.String({ description: "Content to write to the file" }),
244
+ }),
245
+ executionMode: "sequential" as const,
246
+
247
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
248
+ projectCwd = ctx.cwd;
249
+ const absolutePath = resolveSafe(ctx.cwd, params.path);
250
+ let original = "";
251
+ let fileExists = false;
252
+ try {
253
+ original = readFileSync(absolutePath, "utf-8");
254
+ fileExists = true;
255
+ } catch {
256
+ // new file
257
+ }
258
+
259
+ if (fileExists && original === params.content) {
260
+ return {
261
+ content: [{ type: "text", text: `No changes — ${params.path} content is identical.` }],
262
+ details: { path: params.path, status: "no-change" },
263
+ };
264
+ }
265
+
266
+ const description = fileExists ? `Update: ${params.path}` : `Create: ${params.path}`;
267
+ const result = await createReviewAndWait(ctx, params.path, original, params.content, description);
268
+
269
+ switch (result.status) {
270
+ case "timeout":
271
+ return {
272
+ content: [{ type: "text", text: `⏰ Review timed out for ${params.path} (10m)` }],
273
+ details: { path: params.path, status: "timeout" },
274
+ };
275
+ case "approved":
276
+ return withFileMutationQueue(absolutePath, async () => {
277
+ mkdirSync(dirname(absolutePath), { recursive: true });
278
+ writeFileSync(absolutePath, result.final, "utf-8");
279
+ return {
280
+ content: [{ type: "text", text: `✅ ${params.path} — approved (${result.final.length} bytes)` }],
281
+ details: { path: params.path, status: "approved", bytes: result.final.length },
282
+ };
283
+ });
284
+ case "rethink":
285
+ return {
286
+ isError: true,
287
+ content: [{ type: "text", text: `🔄 ${params.path} — rethinking requested: "${result.prompt}"\nPlease reconsider your changes based on this feedback.` }],
288
+ details: { path: params.path, status: "rethink", prompt: result.prompt },
289
+ };
290
+ case "rejected":
291
+ return {
292
+ isError: true,
293
+ content: [{ type: "text", text: `❌ ${params.path} — change REJECTED by user. File was NOT modified.` }],
294
+ details: { path: params.path, status: "rejected" },
295
+ };
296
+ default:
297
+ throw new Error(`Unexpected review status: ${(result as any).status}`);
298
+ }
299
+ },
300
+ });
301
+ }
302
+
303
+ // ─── Override `edit` ─────────────────────────────────────────────────
304
+
305
+ function registerEditOverride(pi: ExtensionAPI) {
306
+ pi.registerTool({
307
+ name: "edit",
308
+ label: "edit (with review)",
309
+ description:
310
+ "Edit a file by replacing exact text passages. Instead of editing directly, creates a review " +
311
+ "request so the user can approve or reject in VS Code or directly in the terminal.",
312
+ promptSnippet: "Make targeted edits to existing files with user review",
313
+ promptGuidelines: [
314
+ "Use edit for targeted changes to existing files — user review is required.",
315
+ "The tool blocks until the user approves or rejects.",
316
+ ],
317
+ parameters: Type.Object({
318
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
319
+ edits: Type.Array(
320
+ Type.Object({
321
+ oldText: Type.String({ description: "Exact unique text to replace" }),
322
+ newText: Type.String({ description: "Replacement text" }),
323
+ }),
324
+ {
325
+ description:
326
+ "Targeted replacements. Each oldText must be unique and non-overlapping.",
327
+ },
328
+ ),
329
+ }),
330
+ executionMode: "sequential" as const,
331
+
332
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
333
+ projectCwd = ctx.cwd;
334
+ const absolutePath = resolveSafe(ctx.cwd, params.path);
335
+
336
+ let original: string;
337
+ try {
338
+ original = readFileSync(absolutePath, "utf-8");
339
+ } catch {
340
+ return {
341
+ content: [{ type: "text", text: `❌ File not found: ${params.path}` }],
342
+ details: { path: params.path, status: "error", error: "not found" },
343
+ };
344
+ }
345
+
346
+ // Apply edits in-memory to get proposed content
347
+ let proposed: string;
348
+ try {
349
+ proposed = applyEdits(original, params.edits);
350
+ } catch (e: any) {
351
+ return {
352
+ content: [{ type: "text", text: `❌ Edit failed: ${e.message} in ${params.path}` }],
353
+ details: { path: params.path, status: "error", error: e.message },
354
+ };
355
+ }
356
+
357
+ if (original === proposed) {
358
+ return {
359
+ content: [{ type: "text", text: `No changes — ${params.path} content is identical after edits.` }],
360
+ details: { path: params.path, status: "no-change" },
361
+ };
362
+ }
363
+
364
+ const result = await createReviewAndWait(ctx, params.path, original, proposed, `Edit: ${params.path}`);
365
+
366
+ switch (result.status) {
367
+ case "timeout":
368
+ return {
369
+ content: [{ type: "text", text: `⏰ Review timed out for ${params.path} (10m)` }],
370
+ details: { path: params.path, status: "timeout" },
371
+ };
372
+ case "approved":
373
+ return withFileMutationQueue(absolutePath, async () => {
374
+ mkdirSync(dirname(absolutePath), { recursive: true });
375
+ writeFileSync(absolutePath, result.final, "utf-8");
376
+ return {
377
+ content: [{ type: "text", text: `✅ ${params.path} — edit approved (${result.final.length} bytes)` }],
378
+ details: { path: params.path, status: "approved", bytes: result.final.length },
379
+ };
380
+ });
381
+ case "rethink":
382
+ return {
383
+ isError: true,
384
+ content: [{ type: "text", text: `🔄 ${params.path} — rethinking requested: "${result.prompt}"\nPlease reconsider your changes based on this feedback.` }],
385
+ details: { path: params.path, status: "rethink", prompt: result.prompt },
386
+ };
387
+ case "rejected":
388
+ return {
389
+ isError: true,
390
+ content: [{ type: "text", text: `❌ ${params.path} — edit REJECTED by user. File was NOT modified.` }],
391
+ details: { path: params.path, status: "rejected" },
392
+ };
393
+ default:
394
+ throw new Error(`Unexpected review status: ${(result as any).status}`);
395
+ }
396
+ },
397
+ });
398
+ }
399
+
400
+ // ─── Extension entry point ───────────────────────────────────────────
401
+
402
+ export default function (pi: ExtensionAPI) {
403
+ // Reset review ID tracking on new session
404
+ pi.on("session_start", () => {
405
+ sessionReviewIds.clear();
406
+ });
407
+
408
+ // Reset Approve All on message boundaries
409
+ const clearApproveAll = () => {
410
+ sessionApproveAll.clear();
411
+ };
412
+ pi.on("message_start", clearApproveAll);
413
+ pi.on("message_end", () => {
414
+ clearApproveAll();
415
+ cleanupPiDir();
416
+ });
417
+
418
+ registerWriteOverride(pi);
419
+ registerEditOverride(pi);
420
+ }
421
+
422
+ function cleanupPiDir() {
423
+ if (!projectCwd) return;
424
+ // Clean up all .pi subdirectories: tmp files, pending requests, and results.
425
+ // After message_end, every review is resolved — no files are needed anymore.
426
+ for (const sub of ["tmp", "review-requests", "review-results"]) {
427
+ const dir = join(projectCwd, ".pi", sub);
428
+ try {
429
+ const files = readdirSync(dir);
430
+ for (const f of files) {
431
+ rmSync(join(dir, f), { recursive: true, force: true });
432
+ }
433
+ } catch {
434
+ // Directory doesn't exist or is empty — ok
435
+ }
436
+ }
437
+ }