@geminixiang/pi-simplify 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +42 -19
  2. package/index.ts +721 -64
  3. package/package.json +32 -21
  4. package/tsconfig.json +11 -0
package/README.md CHANGED
@@ -1,47 +1,70 @@
1
1
  # pi-simplify
2
2
 
3
- A [pi coding agent](https://github.com/mariozechner/pi) extension that adds a `/simplify` command to review changed code for reuse, quality, and efficiency — then fix any issues found.
3
+ A [pi coding agent](https://github.com/mariozechner/pi) extension that cleans up leftover code after feature implementation.
4
4
 
5
5
  ## What it does
6
6
 
7
- Running `/simplify` launches three parallel sub-agents that each review your git diff from a different angle:
7
+ After implementing a feature, your code often accumulates:
8
8
 
9
- - **Code Reuse** — finds newly written code that duplicates existing utilities or helpers
10
- - **Code Quality** — catches hacky patterns: redundant state, parameter sprawl, copy-paste blocks, leaky abstractions, stringly-typed code, unnecessary comments
11
- - **Efficiency** — spots unnecessary work, missed concurrency, hot-path bloat, memory leaks, and overly broad operations
9
+ - **Dead code** - unused exports, orphaned files, zombie variables
10
+ - **Debug remnants** - console.log, debugger statements, temp flags
11
+ - **Commented-out code** - old logic left in comments
12
+ - **Over-engineering** - "might use later" abstractions never used
13
+ - **Duplicate logic** - repeated if-else blocks doing the same thing
12
14
 
13
- After all three agents finish, issues are aggregated and fixed in place. A brief summary is printed at the end.
15
+ `/simplify` finds these and removes them.
14
16
 
15
17
  ## Installation
16
18
 
17
- ### 1. Install pi-subagents
18
-
19
- `pi-simplify` relies on the Agent tool provided by the `pi-subagents` package. Install it first:
20
-
21
- ```sh
22
- pi install npm:pi-subagents
23
- ```
24
-
25
- ### 2. Install pi-simplify
26
-
27
19
  ```sh
28
20
  pi install npm:@geminixiang/pi-simplify
29
21
  ```
30
22
 
31
23
  ## Usage
32
24
 
33
- After installation, the `/simplify` command is available inside pi:
25
+ ### Full Simplify
34
26
 
35
27
  ```
36
28
  /simplify
37
29
  ```
38
30
 
39
- You can also pass an optional focus area to narrow the review:
31
+ Analyzes all git changes and presents cleanup candidates:
32
+
33
+ - **Safe** (green) - auto-selected, will be deleted
34
+ - **Confirm** (yellow) - delete after user confirms
35
+ - **Review** (orange) - user should review first
36
+
37
+ ### Quick Simplify
40
38
 
41
39
  ```
42
- /simplify focus on performance in the database layer
40
+ /simplify-quick
43
41
  ```
44
42
 
43
+ Only removes the obviously safe stuff:
44
+
45
+ - `console.log` / `console.warn` / `console.error`
46
+ - `debugger` statements
47
+ - Unused imports
48
+ - Empty catch blocks
49
+
50
+ No confirmation needed - just does it.
51
+
52
+ ### With Focus
53
+
54
+ ```
55
+ /simplify focus on the utils folder
56
+ /simplify focus on removing debug code
57
+ ```
58
+
59
+ ## Comparison with pi-review
60
+
61
+ | | pi-review | pi-simplify |
62
+ | ------------ | --------------------------- | --------------------- |
63
+ | **Goal** | Find problems | Delete excess |
64
+ | **Attitude** | Conservative (marks issues) | Active (removes junk) |
65
+ | **Output** | Findings list | Deletion plan |
66
+ | **Trigger** | Manual review | Post-feature cleanup |
67
+
45
68
  ## License
46
69
 
47
70
  MIT
package/index.ts CHANGED
@@ -1,89 +1,746 @@
1
+ /**
2
+ * pi-simplify: Code Cleanup Extension for pi
3
+ *
4
+ * Removes leftover code after feature implementation:
5
+ * - Dead code (unused exports, orphaned files)
6
+ * - Debug remnants (console.log, debugger, temp flags)
7
+ * - Commented-out code
8
+ * - Over-engineering ("might use later" abstractions)
9
+ * - Duplicate logic
10
+ */
11
+
12
+ import { writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
1
16
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
17
+ import { matchesKey, Key, truncateToWidth, type SelectItem } from "@mariozechner/pi-tui";
18
+
19
+ type SimplifyResult = {
20
+ file: string;
21
+ lines: string;
22
+ reason: string;
23
+ risk: "safe" | "confirm" | "review";
24
+ };
2
25
 
3
- const CUSTOM_TYPE = "simplify-context";
4
- const DELIVERY_FOLLOW_UP = "followUp";
5
- const NOTIFY_REVIEW_STARTED = "Simplify review started";
6
- const NOTIFY_REVIEW_QUEUED = "Simplify review queued as a follow-up";
26
+ const SIMPLIFY_PROMPT = `# Simplify: Clean Up Leftover Code
7
27
 
8
- const SIMPLIFY_PROMPT = `# Simplify: Code Review and Cleanup
28
+ You are a code cleanup assistant. Your job is to identify and remove unnecessary code left over after feature implementation.
9
29
 
10
- Review all changed files for reuse, quality, and efficiency. Fix any issues found.
30
+ ## What to Find
11
31
 
12
- ## Phase 1: Identify Changes
32
+ ### 1. Dead Code (Safe to Delete)
33
+ - **Unused exports**: Functions/classes defined but never imported elsewhere
34
+ - **Orphan files**: Files created but never referenced
35
+ - **Zombie variables**: Variables assigned but never used
36
+ - **Empty blocks**: try/catch/if blocks with no logic
13
37
 
14
- Run \`git diff\` (or \`git diff HEAD\` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.
38
+ ### 2. Debug Remnants (Safe to Delete)
39
+ - console.log, console.warn, console.error statements
40
+ - debugger statements
41
+ - Temporary feature flags (e.g., \`ENABLE_DEBUG\`, \`DEBUG_MODE\`)
42
+ - Temporary todo comments that are now implemented
15
43
 
16
- ## Phase 2: Launch Three Review SubAgents in Parallel
44
+ ### 3. Commented-out Code (Review Before Deleting)
45
+ - Old logic left in comments
46
+ - Disabled features (commented out rather than deleted)
47
+ - Copy-pasted templates never customized
17
48
 
18
- Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.
49
+ ### 4. Over-engineering (Confirm Before Deleting)
50
+ - Abstractions created "for future use" but never used
51
+ - Helper functions with single call sites (could be inlined)
52
+ - Layers of indirection that add no value
19
53
 
20
- ### SubAgent 1: Code Reuse Review
54
+ ### 5. Duplicate Logic (Confirm Before Deleting)
55
+ - Repeated if-else blocks doing the same thing
56
+ - Copy-paste code with minor variations
57
+ - Duplicate utility functions
21
58
 
22
- For each change:
59
+ ## How to Analyze
23
60
 
24
- 1. **Search for existing utilities and helpers** that could replace newly written code. Look for similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.
25
- 2. **Flag any new function that duplicates existing functionality.** Suggest the existing function to use instead.
26
- 3. **Flag any inline logic that could use an existing utility** — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.
61
+ 1. Run \`git diff\` to see what changed
62
+ 2. For each change, determine if it's:
63
+ - **Essential**: Required for the feature to work
64
+ - **Residual**: Left over from development/debugging
65
+ - **Legacy**: Old code not touched by this change
27
66
 
28
- ### SubAgent 2: Code Quality Review
67
+ 3. For each residual item:
68
+ - Identify exact file and line(s)
69
+ - Assess risk level
70
+ - Provide clear reason for removal
29
71
 
30
- Review the same changes for hacky patterns:
72
+ ## Risk Levels
31
73
 
32
- 1. **Redundant state**: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls
33
- 2. **Parameter sprawl**: adding new parameters to a function instead of generalizing or restructuring existing ones
34
- 3. **Copy-paste with slight variation**: near-duplicate code blocks that should be unified with a shared abstraction
35
- 4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries
36
- 5. **Stringly-typed code**: using raw strings where constants, enums (string unions), or branded types already exist in the codebase
37
- 6. **Unnecessary JSX nesting**: wrapper Boxes/elements that add no layout value — check if inner component props (flexShrink, alignItems, etc.) already provide the needed behavior
38
- 7. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds)
74
+ - **safe**: Definitely can be deleted (unused, debug code)
75
+ - **confirm**: Delete after user confirms (over-engineered, duplicates)
76
+ - **review**: User should review before action (commented code, ambiguous)
39
77
 
40
- ### SubAgent 3: Efficiency Review
78
+ ## Rules
41
79
 
42
- Review the same changes for efficiency:
80
+ 1. When in doubt, mark as "confirm" or "review" - don't delete without consent
81
+ 2. For inline candidates, show the alternative
82
+ 3. Don't flag necessary code just because it's simple
83
+ 4. Respect existing abstraction boundaries
84
+ 5. Be especially careful with:
85
+ - Error handling code
86
+ - Security-related logic
87
+ - Code that looks "unused" but is called via reflection/eval
88
+ - Database migration files
43
89
 
44
- 1. **Unnecessary work**: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns
45
- 2. **Missed concurrency**: independent operations run sequentially when they could run in parallel
46
- 3. **Hot-path bloat**: new blocking work added to startup or per-request/per-render hot paths
47
- 4. **Recurring no-op updates**: state/store updates inside polling loops, intervals, or event handlers that fire unconditionally — add a change-detection guard so downstream consumers aren't notified when nothing changed. Also: if a wrapper function takes an updater/reducer callback, verify it honors same-reference returns (or whatever the "no change" signal is) — otherwise callers' early-return no-ops are silently defeated
48
- 5. **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error
49
- 6. **Memory**: unbounded data structures, missing cleanup, event listener leaks
50
- 7. **Overly broad operations**: reading entire files when only a portion is needed, loading all items when filtering for one
90
+ ## Output Format (REQUIRED)
51
91
 
52
- ## Phase 3: Fix Issues
92
+ You may write prose analysis first, but you MUST end your response with a single fenced JSON block containing ALL candidates. This JSON is parsed programmatically — it must be valid JSON and use this exact shape:
93
+
94
+ \`\`\`json
95
+ {
96
+ "candidates": [
97
+ {
98
+ "risk": "safe",
99
+ "file": "path/to/file.ext",
100
+ "lines": "12-15",
101
+ "type": "debug",
102
+ "reason": "Why this should be removed",
103
+ "action": "delete"
104
+ }
105
+ ]
106
+ }
107
+ \`\`\`
53
108
 
54
- Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.
109
+ Field notes:
110
+ - \`risk\` — one of: "safe", "confirm", "review"
111
+ - \`file\` — repository-relative path, no backticks, no markdown
112
+ - \`lines\` — line number or range ("42" or "42-57"); empty string if unknown
113
+ - \`type\` — one of: "dead-code", "debug", "commented", "over-eng", "duplicate"
114
+ - \`reason\` — single plain-text sentence
115
+ - \`action\` — one of: "delete", "inline", "confirm"
55
116
 
56
- When done, briefly summarize what was fixed (or confirm the code was already clean).
117
+ If there are no candidates, output \`{"candidates": []}\`. Do NOT output anything after the closing \`\`\` fence.
57
118
  `;
58
119
 
59
- function buildPrompt(args: string): string {
60
- const focus = args.trim();
61
- if (!focus) return SIMPLIFY_PROMPT;
62
- return `${SIMPLIFY_PROMPT}\n\n## Additional Focus\n\n${focus}`;
63
- }
120
+ export default function simplifyExtension(pi: ExtensionAPI) {
121
+ // Accumulate assistant text across the whole turn (multiple messages when
122
+ // the AI makes tool calls). Overwriting would drop the JSON block if it
123
+ // appears in an earlier message and a later one adds a short confirmation.
124
+ let assistantTextBuffer = "";
125
+ pi.on("message_end", (event) => {
126
+ const msg = event.message;
127
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) return;
128
+ const text = (msg.content as { type: string; text?: string }[])
129
+ .filter((b) => b.type === "text" && typeof b.text === "string")
130
+ .map((b) => b.text as string)
131
+ .join("\n");
132
+ if (text) assistantTextBuffer += (assistantTextBuffer ? "\n" : "") + text;
133
+ });
64
134
 
65
- function runSimplify(pi: ExtensionAPI, ctx: ExtensionCommandContext, args: string): void {
66
- const message = {
67
- customType: CUSTOM_TYPE,
68
- content: buildPrompt(args),
69
- display: false,
70
- };
71
-
72
- if (ctx.isIdle()) {
73
- pi.sendMessage(message, { triggerTurn: true });
74
- ctx.ui.notify(NOTIFY_REVIEW_STARTED, "info");
75
- return;
76
- }
77
-
78
- pi.sendMessage(message, { deliverAs: DELIVERY_FOLLOW_UP, triggerTurn: true });
79
- ctx.ui.notify(NOTIFY_REVIEW_QUEUED, "info");
80
- }
135
+ const RISK_VALUES = new Set(["safe", "confirm", "review"]);
136
+
137
+ function normalizeRisk(value: unknown): "safe" | "confirm" | "review" {
138
+ const v = String(value ?? "")
139
+ .toLowerCase()
140
+ .trim();
141
+ return RISK_VALUES.has(v) ? (v as "safe" | "confirm" | "review") : "review";
142
+ }
143
+
144
+ function stripMarkdown(text: string): string {
145
+ return text.replace(/[`*_]/g, "").trim();
146
+ }
147
+
148
+ /**
149
+ * Try to parse a JSON candidates block. The prompt requires this format,
150
+ * but we tolerate the AI wrapping it in ```json ... ``` or ``` ... ```,
151
+ * and also handle the case where it embeds the JSON inline without fences.
152
+ * Returns null only when no valid JSON with a `candidates` array is found —
153
+ * a valid empty list returns [] (which means "the AI found nothing").
154
+ */
155
+ function parseCandidatesJSON(response: string): SimplifyResult[] | null {
156
+ const fenceRe = /```(?:json)?\s*\n([\s\S]*?)\n```/gi;
157
+ const blocks: string[] = [];
158
+ let m: RegExpExecArray | null;
159
+ while ((m = fenceRe.exec(response)) !== null) blocks.push(m[1]);
160
+
161
+ const bareMatch = response.match(/\{[\s\S]*"candidates"[\s\S]*\}/);
162
+ if (bareMatch) blocks.push(bareMatch[0]);
163
+
164
+ // Walk newest-first: the contract puts the JSON at the end of the response.
165
+ for (let i = blocks.length - 1; i >= 0; i--) {
166
+ try {
167
+ const parsed = JSON.parse(blocks[i]);
168
+ const list = parsed?.candidates;
169
+ if (!Array.isArray(list)) continue;
170
+ const out: SimplifyResult[] = [];
171
+ for (const item of list) {
172
+ if (!item || typeof item !== "object") continue;
173
+ const file = stripMarkdown(String(item.file ?? item.location ?? ""));
174
+ if (!file) continue;
175
+ const reason = stripMarkdown(String(item.reason ?? item.description ?? ""));
176
+ const action = item.action ? ` [${stripMarkdown(String(item.action))}]` : "";
177
+ out.push({
178
+ file,
179
+ lines: stripMarkdown(String(item.lines ?? "")),
180
+ reason: reason ? reason + action : action.trim() || "(no reason provided)",
181
+ risk: normalizeRisk(item.risk),
182
+ });
183
+ }
184
+ // A valid candidates array (even empty) is a successful parse
185
+ return out;
186
+ } catch {
187
+ // try next block
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * Tolerant markdown parser — fallback for responses that don't emit the JSON block.
195
+ * Accepts many header/bullet variations that LLMs produce in practice.
196
+ */
197
+ function parseCandidatesMarkdown(response: string): SimplifyResult[] {
198
+ const candidates: SimplifyResult[] = [];
199
+ const lines = response.split("\n");
200
+
201
+ // Match `[safe]` / `[confirm]` / `[review]` anywhere on a "header-ish" line.
202
+ // A line counts as a header if the risk tag is preceded only by markdown noise:
203
+ // whitespace, #, *, -, digits, dots, parens, colons, backticks, underscores.
204
+ const HEADER_PREFIX = /^[\s#*\-–—•▸→0-9.)\]:`_]*$/;
205
+ const RISK_TAG = /\[(safe|confirm|review)\]/i;
206
+
207
+ let current: (Partial<SimplifyResult> & { _reason?: string; _action?: string }) | null = null;
208
+
209
+ const commit = () => {
210
+ if (!current) return;
211
+ if (!current.risk || !current.file) {
212
+ current = null;
213
+ return;
214
+ }
215
+ const reason = current._reason?.trim() || current.lines?.trim() || "(no reason provided)";
216
+ const action = current._action ? ` [${current._action.trim()}]` : "";
217
+ candidates.push({
218
+ file: current.file,
219
+ lines: current.lines || "",
220
+ reason: reason + action,
221
+ risk: current.risk as "safe" | "confirm" | "review",
222
+ });
223
+ current = null;
224
+ };
225
+
226
+ for (const rawLine of lines) {
227
+ const line = rawLine;
228
+ const tagMatch = line.match(RISK_TAG);
229
+ if (tagMatch) {
230
+ const prefix = line.slice(0, line.indexOf(tagMatch[0]));
231
+ if (HEADER_PREFIX.test(prefix)) {
232
+ // New candidate — commit the previous one
233
+ commit();
234
+ const risk = normalizeRisk(tagMatch[1]);
235
+ // Everything after the tag on this line
236
+ const rest = stripMarkdown(
237
+ line.slice(line.indexOf(tagMatch[0]) + tagMatch[0].length).replace(/^[\s\-–—:]+/, ""),
238
+ );
239
+ // Try to extract a file path (with optional :line or :line-line)
240
+ const fileMatch = rest.match(/([^\s()`*,]+?\.[a-zA-Z0-9]+)(?::(\d+(?:-\d+)?))?/);
241
+ current = { risk };
242
+ if (fileMatch) {
243
+ current.file = fileMatch[1];
244
+ current.lines = fileMatch[2] || "";
245
+ // Remaining text becomes a description/reason fallback
246
+ const desc = rest
247
+ .replace(fileMatch[0], "")
248
+ .replace(/^[\s\-–—:]+/, "")
249
+ .trim();
250
+ if (desc) current.lines = current.lines ? `${current.lines}: ${desc}` : desc;
251
+ } else {
252
+ // No recognizable file on header — treat rest as description
253
+ current.lines = rest;
254
+ }
255
+ continue;
256
+ }
257
+ }
258
+
259
+ if (!current) continue;
260
+
261
+ // Field extraction: tolerate `- Key:`, `* Key:`, `**Key:**`, or bare `Key:`.
262
+ const fieldMatch = line.match(
263
+ /^\s*(?:[-*•]\s*)?\*{0,2}(File|Location|Path|Reason|Why|Action|Lines?)\*{0,2}\s*:?\s*[:\-–—]?\s*(.+?)\s*$/i,
264
+ );
265
+ if (fieldMatch) {
266
+ const key = fieldMatch[1].toLowerCase();
267
+ const value = stripMarkdown(fieldMatch[2]);
268
+ if (key === "file" || key === "location" || key === "path") {
269
+ // Split `path.ts:12-15` into file + lines if current.file is still missing/weak
270
+ const withLine = value.match(/^(.+?)(?::(\d+(?:-\d+)?))?$/);
271
+ if (withLine) {
272
+ current.file = current.file || withLine[1];
273
+ if (withLine[2]) current.lines = withLine[2];
274
+ } else {
275
+ current.file = current.file || value;
276
+ }
277
+ } else if (key === "reason" || key === "why") {
278
+ current._reason = value;
279
+ } else if (key === "action") {
280
+ current._action = value;
281
+ } else if (key === "line" || key === "lines") {
282
+ current.lines = value;
283
+ }
284
+ }
285
+ }
286
+
287
+ commit();
288
+ return candidates;
289
+ }
290
+
291
+ /** Write raw AI output to a debug file so the user can see what the model actually returned. */
292
+ function dumpDebug(response: string): string {
293
+ const path = join(tmpdir(), `pi-simplify-debug-${Date.now()}.md`);
294
+ try {
295
+ writeFileSync(path, response, "utf8");
296
+ return path;
297
+ } catch {
298
+ return "";
299
+ }
300
+ }
301
+
302
+ type ParseOutcome = { kind: "ok"; candidates: SimplifyResult[] } | { kind: "unparseable" };
303
+
304
+ /**
305
+ * Parse the AI's response to extract cleanup candidates.
306
+ * - "ok" with non-empty list → show selector
307
+ * - "ok" with empty list → AI legitimately found nothing
308
+ * - "unparseable" → AI output didn't match any known format; dump for debug
309
+ */
310
+ function parseCandidates(response: string): ParseOutcome {
311
+ const json = parseCandidatesJSON(response);
312
+ if (json !== null) return { kind: "ok", candidates: json };
313
+ const md = parseCandidatesMarkdown(response);
314
+ if (md.length > 0) return { kind: "ok", candidates: md };
315
+ return { kind: "unparseable" };
316
+ }
317
+
318
+ /**
319
+ * Show selection dialog for candidates with proper multi-select support
320
+ */
321
+ async function showCandidateSelector(
322
+ ctx: ExtensionCommandContext,
323
+ candidates: SimplifyResult[],
324
+ ): Promise<SimplifyResult[]> {
325
+ if (candidates.length === 0) {
326
+ return [];
327
+ }
328
+
329
+ // Categorize by risk level for display
330
+ const safeCandidates = candidates.filter((c) => c.risk === "safe");
331
+ const confirmCandidates = candidates.filter((c) => c.risk === "confirm");
332
+ const reviewCandidates = candidates.filter((c) => c.risk === "review");
333
+
334
+ // Build display items with section headers
335
+ const displayItems: SelectItem[] = [];
336
+ const selectableItems: { index: number; candidate: SimplifyResult }[] = [];
337
+ let globalIndex = 0;
338
+
339
+ if (safeCandidates.length > 0) {
340
+ displayItems.push({
341
+ value: "__section__safe__",
342
+ label: `── Safe to delete (${safeCandidates.length}) ──`,
343
+ description: "",
344
+ });
345
+ globalIndex++;
346
+ for (const c of safeCandidates) {
347
+ displayItems.push({
348
+ value: JSON.stringify(c),
349
+ label: `${c.file} - ${c.reason}`,
350
+ description: "Will be deleted",
351
+ });
352
+ selectableItems.push({ index: globalIndex, candidate: c });
353
+ globalIndex++;
354
+ }
355
+ }
356
+
357
+ if (confirmCandidates.length > 0) {
358
+ displayItems.push({
359
+ value: "__section__confirm__",
360
+ label: `── Needs confirmation (${confirmCandidates.length}) ──`,
361
+ description: "",
362
+ });
363
+ globalIndex++;
364
+ for (const c of confirmCandidates) {
365
+ displayItems.push({
366
+ value: JSON.stringify(c),
367
+ label: `${c.file} - ${c.reason}`,
368
+ description: "Select to delete",
369
+ });
370
+ selectableItems.push({ index: globalIndex, candidate: c });
371
+ globalIndex++;
372
+ }
373
+ }
374
+
375
+ if (reviewCandidates.length > 0) {
376
+ displayItems.push({
377
+ value: "__section__review__",
378
+ label: `── Needs review (${reviewCandidates.length}) ──`,
379
+ description: "",
380
+ });
381
+ globalIndex++;
382
+ for (const c of reviewCandidates) {
383
+ displayItems.push({
384
+ value: JSON.stringify(c),
385
+ label: `${c.file} - ${c.reason}`,
386
+ description: "Review before deleting",
387
+ });
388
+ selectableItems.push({ index: globalIndex, candidate: c });
389
+ globalIndex++;
390
+ }
391
+ }
392
+
393
+ // Track selected selectable items (by their selectableItems index)
394
+ const selectedSet = new Set<number>();
395
+
396
+ // Pre-select all safe items (indices 0 to safeCandidates.length - 1 in selectableItems)
397
+ for (let i = 0; i < safeCandidates.length; i++) {
398
+ selectedSet.add(i);
399
+ }
400
+
401
+ const maxVisible = 12;
402
+ let scrollOffset = 0;
403
+ let cursorPos = 0;
404
+
405
+ const result = await ctx.ui.custom<SimplifyResult[]>((tui, theme, _keybindings, done) => {
406
+ // Render the multi-select list manually
407
+ const renderList = (width: number): string[] => {
408
+ const lines: string[] = [];
409
+
410
+ // Calculate which items are visible
411
+ const visibleItems: { selectableIdx: number | null; item: SelectItem }[] = [];
412
+ for (let i = 0; i < displayItems.length; i++) {
413
+ const item = displayItems[i];
414
+ if (item.value.startsWith("__section__")) {
415
+ visibleItems.push({ selectableIdx: null, item });
416
+ } else {
417
+ const selectableInfo = selectableItems.find((si) => si.index === i);
418
+ if (selectableInfo) {
419
+ const selectableIdx = selectableItems.indexOf(selectableInfo);
420
+ visibleItems.push({ selectableIdx, item });
421
+ }
422
+ }
423
+ }
424
+
425
+ // Show visible range with scroll
426
+ const startIdx = Math.max(0, scrollOffset);
427
+ const endIdx = Math.min(visibleItems.length, startIdx + maxVisible);
428
+
429
+ for (let vIdx = startIdx; vIdx < endIdx; vIdx++) {
430
+ const { selectableIdx, item } = visibleItems[vIdx];
431
+
432
+ if (item.value.startsWith("__section__")) {
433
+ // Section header
434
+ lines.push(theme.fg("accent", item.label));
435
+ } else {
436
+ // Selectable item
437
+ const isCursor = selectableIdx === cursorPos;
438
+ const isSelected = selectableIdx !== null && selectedSet.has(selectableIdx);
439
+ const prefix = isCursor ? theme.fg("accent", "> ") : " ";
440
+ const checkbox = isSelected ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
441
+ const truncatedLabel = truncateToWidth(item.label, width - 6);
442
+ const line = `${prefix}${checkbox} ${truncatedLabel}`;
443
+ lines.push(isCursor ? theme.fg("accent", line) : line);
444
+ }
445
+ }
446
+
447
+ // Scroll indicator
448
+ if (visibleItems.length > maxVisible) {
449
+ const scrollInfo = ` (${startIdx + 1}-${endIdx}/${visibleItems.length})`;
450
+ lines.push(theme.fg("dim", scrollInfo));
451
+ }
452
+
453
+ return lines;
454
+ };
455
+
456
+ return {
457
+ render(width: number) {
458
+ const lines: string[] = [];
459
+ lines.push(theme.bold("Simplify: Select items to remove"));
460
+ lines.push(
461
+ theme.fg(
462
+ "muted",
463
+ `Found ${candidates.length} candidates (${safeCandidates.length} safe, ${confirmCandidates.length} confirm, ${reviewCandidates.length} review)`,
464
+ ),
465
+ );
466
+ lines.push("");
467
+ lines.push(...renderList(width));
468
+ lines.push("");
469
+ lines.push(
470
+ theme.fg(
471
+ "muted",
472
+ `Selected: ${selectedSet.size} • ↑↓ navigate • space toggle • a select all • enter confirm • esc cancel`,
473
+ ),
474
+ );
475
+ return lines;
476
+ },
477
+ invalidate() {},
478
+ handleInput(data: string) {
479
+ if (matchesKey(data, Key.enter)) {
480
+ const results = Array.from(selectedSet).map((i) => selectableItems[i].candidate);
481
+ done(results);
482
+ return;
483
+ }
484
+ if (matchesKey(data, Key.escape)) {
485
+ done([]);
486
+ return;
487
+ }
488
+ if (matchesKey(data, Key.space)) {
489
+ // Toggle current selection
490
+ if (selectedSet.has(cursorPos)) {
491
+ selectedSet.delete(cursorPos);
492
+ } else {
493
+ selectedSet.add(cursorPos);
494
+ }
495
+ tui.requestRender();
496
+ return;
497
+ }
498
+ if (matchesKey(data, "a")) {
499
+ // Select/deselect all
500
+ if (selectedSet.size === selectableItems.length) {
501
+ selectedSet.clear();
502
+ } else {
503
+ for (let i = 0; i < selectableItems.length; i++) {
504
+ selectedSet.add(i);
505
+ }
506
+ }
507
+ tui.requestRender();
508
+ return;
509
+ }
510
+
511
+ // Navigation
512
+ if (matchesKey(data, Key.down)) {
513
+ cursorPos = Math.min(selectableItems.length - 1, cursorPos + 1);
514
+ // Ensure cursor is within visible window
515
+ const displayIdx = selectableItems[cursorPos].index;
516
+ if (displayIdx >= scrollOffset + maxVisible) {
517
+ scrollOffset = displayIdx - maxVisible + 1;
518
+ }
519
+ tui.requestRender();
520
+ return;
521
+ }
522
+ if (matchesKey(data, Key.up)) {
523
+ cursorPos = Math.max(0, cursorPos - 1);
524
+ // Ensure cursor is within visible window
525
+ const displayIdx = selectableItems[cursorPos].index;
526
+ if (displayIdx < scrollOffset) {
527
+ // Scroll up to show the item, and include section header if it's just above
528
+ scrollOffset = Math.max(0, displayIdx - 1);
529
+ }
530
+ tui.requestRender();
531
+ return;
532
+ }
533
+ if (matchesKey(data, Key.home)) {
534
+ cursorPos = 0;
535
+ scrollOffset = 0;
536
+ tui.requestRender();
537
+ return;
538
+ }
539
+ if (matchesKey(data, Key.end)) {
540
+ cursorPos = selectableItems.length - 1;
541
+ const lastDisplayIdx = selectableItems[cursorPos].index;
542
+ scrollOffset = Math.max(0, lastDisplayIdx - maxVisible + 1);
543
+ tui.requestRender();
544
+ return;
545
+ }
546
+ },
547
+ };
548
+ });
549
+
550
+ return result ?? [];
551
+ }
552
+
553
+ /**
554
+ * Execute the cleanup by sending deletion commands
555
+ */
556
+ async function executeCleanup(
557
+ ctx: ExtensionCommandContext,
558
+ selected: SimplifyResult[],
559
+ ): Promise<void> {
560
+ if (selected.length === 0) {
561
+ ctx.ui.notify("No items selected for cleanup", "info");
562
+ return;
563
+ }
564
+
565
+ const safeItems = selected.filter((c) => c.risk === "safe");
566
+ const confirmItems = selected.filter((c) => c.risk === "confirm");
567
+
568
+ // Build cleanup prompt
569
+ const cleanupPrompt = `# Cleanup Instructions
570
+
571
+ Delete the following code:
572
+
573
+ ${selected.map((c) => `- ${c.file}: ${c.reason}`).join("\n")}
574
+
575
+ For each item:
576
+ 1. Read the file to find the exact location
577
+ 2. Remove only the specified code (not surrounding code unless instructed)
578
+ 3. If the removal affects other code, stop and report the issue
579
+ 4. After all deletions, verify the code still works by running any existing tests
580
+
581
+ Report:
582
+ - What was deleted
583
+ - Any issues encountered
584
+ - Files that may need further attention`;
585
+
586
+ ctx.ui.notify(
587
+ `Starting cleanup of ${selected.length} items (${safeItems.length} safe, ${confirmItems.length} confirmed)`,
588
+ "info",
589
+ );
590
+
591
+ pi.sendUserMessage(cleanupPrompt);
592
+ }
593
+
594
+ /**
595
+ * Main command handler
596
+ */
597
+ pi.registerCommand("simplify", {
598
+ description: "Clean up leftover code (dead code, debug remnants, over-engineering)",
599
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
600
+ if (!ctx.hasUI) {
601
+ ctx.ui.notify("Simplify requires interactive mode", "error");
602
+ return;
603
+ }
604
+
605
+ if (!ctx.isIdle()) {
606
+ ctx.ui.notify(
607
+ "Cannot run simplify while agent is busy. Wait for current task to complete.",
608
+ "warning",
609
+ );
610
+ return;
611
+ }
612
+
613
+ // Check if in git repo
614
+ const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
615
+ if (code !== 0) {
616
+ ctx.ui.notify("Not a git repository", "error");
617
+ return;
618
+ }
619
+
620
+ // Check if there are changes
621
+ const { stdout: gitStatus } = await pi.exec("git", ["status", "--porcelain"]);
622
+ if (!gitStatus.trim()) {
623
+ ctx.ui.notify("No git changes found. Simplify reviews uncommitted changes.", "info");
624
+ return;
625
+ }
626
+
627
+ ctx.ui.notify("Analyzing code for cleanup candidates...", "info");
628
+
629
+ // Build prompt with optional focus
630
+ let fullPrompt = SIMPLIFY_PROMPT;
631
+ if (args.trim()) {
632
+ fullPrompt += `\n\n## Additional Focus\n\n${args.trim()}`;
633
+ }
634
+
635
+ // Reset capture buffer, then send prompt and wait.
636
+ // message_end events append to assistantTextBuffer during the turn.
637
+ //
638
+ // Race condition fix: pi.sendUserMessage() queues the message, but
639
+ // ctx.waitForIdle() resolves immediately if the agent is still in idle.
640
+ // We must wait for the agent to transition out of idle before waiting.
641
+ assistantTextBuffer = "";
642
+ let resolveTurnStarted: () => void;
643
+ const turnStarted = new Promise<void>((r) => {
644
+ resolveTurnStarted = r;
645
+ });
646
+ pi.on("turn_start", () => resolveTurnStarted!());
647
+ pi.sendUserMessage(fullPrompt);
648
+ await turnStarted;
649
+ await ctx.waitForIdle();
650
+ const analysisText = assistantTextBuffer;
651
+
652
+ if (!analysisText) {
653
+ ctx.ui.notify("Could not read analysis — no response captured.", "error");
654
+ return;
655
+ }
656
+
657
+ const outcome = parseCandidates(analysisText);
658
+
659
+ if (outcome.kind === "unparseable") {
660
+ const debugPath = dumpDebug(analysisText);
661
+ const suffix = debugPath ? ` Raw response saved to ${debugPath}` : "";
662
+ ctx.ui.notify(
663
+ `Could not parse cleanup candidates — the model did not return the expected JSON block.${suffix}`,
664
+ "warning",
665
+ );
666
+ return;
667
+ }
668
+
669
+ const candidates = outcome.candidates;
670
+ if (candidates.length === 0) {
671
+ ctx.ui.notify("No cleanup candidates found!", "info");
672
+ return;
673
+ }
674
+
675
+ ctx.ui.notify(`Found ${candidates.length} candidates. Select items to remove...`, "info");
676
+
677
+ // Show selector
678
+ const selected = await showCandidateSelector(ctx, candidates);
679
+
680
+ if (!selected || selected.length === 0) {
681
+ ctx.ui.notify("Cleanup cancelled", "info");
682
+ return;
683
+ }
684
+
685
+ // Execute cleanup
686
+ await executeCleanup(ctx, selected);
687
+ },
688
+ });
689
+
690
+ /**
691
+ * Quick simplify - auto-clean safe items only
692
+ */
693
+ pi.registerCommand("simplify-quick", {
694
+ description: "Quick cleanup of obviously safe items (debug code, unused exports)",
695
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
696
+ if (!ctx.hasUI) {
697
+ ctx.ui.notify("Simplify requires interactive mode", "error");
698
+ return;
699
+ }
700
+
701
+ if (!ctx.isIdle()) {
702
+ ctx.ui.notify("Agent is busy. Try again after current task.", "warning");
703
+ return;
704
+ }
705
+
706
+ const quickPrompt = `# Quick Cleanup
707
+
708
+ Find and delete ONLY the most obviously safe cleanup items:
709
+
710
+ 1. \`console.log\`, \`console.warn\`, \`console.error\` statements
711
+ 2. \`debugger\` statements
712
+ 3. Unused \`import\` statements (imported but never used)
713
+ 4. Empty \`catch\` blocks (just \`catch (e) {}\`)
714
+ 5. Unused \`const\`/\`let\`/\`var\` declarations
715
+
716
+ DO NOT delete:
717
+ - Commented-out code
718
+ - Over-engineered abstractions
719
+ - Duplicate logic
720
+ - Anything that might be needed
721
+
722
+ For each item found:
723
+ 1. Confirm it's truly unused by searching for references
724
+ 2. Delete only the specific lines
725
+ 3. Report what was deleted
726
+
727
+ Return a summary of what was deleted.`;
728
+
729
+ ctx.ui.notify("Running quick cleanup...", "info");
730
+ pi.sendUserMessage(quickPrompt, { deliverAs: "followUp" });
731
+ },
732
+ });
81
733
 
82
- export default function simplifyExtension(pi: ExtensionAPI): void {
83
- pi.registerCommand("simplify", {
84
- description: "Review changed code for reuse, quality, and efficiency, then fix issues",
85
- handler: async (args: string, ctx: ExtensionCommandContext) => {
86
- runSimplify(pi, ctx, args);
87
- },
88
- });
734
+ /**
735
+ * Status indicator on startup
736
+ */
737
+ pi.on("session_start", async (_event, ctx) => {
738
+ const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
739
+ if (code === 0) {
740
+ ctx.ui.setStatus(
741
+ "simplify",
742
+ `${ctx.ui.theme.fg("accent", "simplify")} ${ctx.ui.theme.fg("muted", "ready (try /simplify)")}`,
743
+ );
744
+ }
745
+ });
89
746
  }
package/package.json CHANGED
@@ -1,23 +1,34 @@
1
1
  {
2
- "name": "@geminixiang/pi-simplify",
3
- "version": "0.0.3",
4
- "description": "Simplify: code reuse, quality, and efficiency review for pi coding agent",
5
- "main": "index.ts",
6
- "keywords": [
7
- "pi-package"
8
- ],
9
- "author": "geminixiang",
10
- "repository": {
11
- "type": "git",
12
- "url": "https://github.com/geminixiang/pi-simplify"
13
- },
14
- "license": "MIT",
15
- "pi": {
16
- "extensions": [
17
- "./index.ts"
18
- ]
19
- },
20
- "dependencies": {
21
- "@mariozechner/pi-coding-agent": "^0.65.2"
22
- }
2
+ "name": "@geminixiang/pi-simplify",
3
+ "version": "0.0.4",
4
+ "description": "Simplify: Clean up leftover code after feature implementation",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "author": "geminixiang",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/geminixiang/pi-simplify"
13
+ },
14
+ "main": "index.ts",
15
+ "scripts": {
16
+ "lint": "oxlint",
17
+ "lint:fix": "oxlint --fix",
18
+ "fmt": "oxfmt",
19
+ "fmt:check": "oxfmt --check"
20
+ },
21
+ "dependencies": {
22
+ "@mariozechner/pi-coding-agent": "^0.65.2"
23
+ },
24
+ "devDependencies": {
25
+ "oxfmt": "^0.45.0",
26
+ "oxlint": "^1.60.0",
27
+ "typescript": "^6.0.2"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./index.ts"
32
+ ]
33
+ }
23
34
  }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist"
10
+ }
11
+ }