@geminixiang/pi-simplify 0.0.6 → 0.0.8
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/.pi/skills/release/SKILL.md +146 -0
- package/CHANGELOG.md +35 -0
- package/README.md +6 -11
- package/index.ts +3 -595
- package/package.json +4 -2
- package/prompt.ts +93 -0
- package/selector.ts +176 -0
- package/types.ts +12 -0
- package/workflow.ts +394 -0
package/prompt.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export const SIMPLIFY_PROMPT = `# Simplify: Review Changed Code for Reuse, Quality, and Efficiency
|
|
2
|
+
|
|
3
|
+
You are a code review assistant. Review the changed code along three axes — **Reuse**, **Quality**, and **Efficiency** — and surface concrete fixes.
|
|
4
|
+
|
|
5
|
+
Match the user's language for candidate reasons and summaries; if the user's request is in Chinese, write the candidate \`reason\` fields in Chinese.
|
|
6
|
+
|
|
7
|
+
## What to Find
|
|
8
|
+
|
|
9
|
+
### A. Reuse (Confirm Before Replacing)
|
|
10
|
+
|
|
11
|
+
Before flagging, **search the codebase** (utility directories, shared modules, files adjacent to the change) for existing helpers. Quote the existing symbol you'd use.
|
|
12
|
+
|
|
13
|
+
- **Duplicates an existing function**: a newly written helper does what an existing one already does
|
|
14
|
+
- **Inline logic that has a utility**: hand-rolled string manipulation, manual path handling, custom env checks, ad-hoc type guards
|
|
15
|
+
- **Reinvented framework primitive**: re-implementing something the language/stdlib/framework already provides
|
|
16
|
+
|
|
17
|
+
### B. Quality
|
|
18
|
+
|
|
19
|
+
#### B1. Dead Code (Safe to Delete)
|
|
20
|
+
- Unused exports, orphan files, zombie variables, empty try/catch/if blocks
|
|
21
|
+
|
|
22
|
+
#### B2. Debug Remnants (Safe to Delete)
|
|
23
|
+
- \`console.log\` / \`console.warn\` / \`console.error\`, \`debugger\`, temporary feature flags, stale TODO comments
|
|
24
|
+
|
|
25
|
+
#### B3. Commented-out Code (Review)
|
|
26
|
+
- Old logic left in comments, disabled features, uncustomized templates
|
|
27
|
+
|
|
28
|
+
#### B4. Over-engineering (Confirm)
|
|
29
|
+
- Abstractions created "for future use" but unused, single-call-site helpers that should be inlined, useless indirection
|
|
30
|
+
|
|
31
|
+
#### B5. Hacky Patterns (Confirm)
|
|
32
|
+
- **Redundant state**: state that duplicates other state, cached values that could be derived, observers that could be direct calls
|
|
33
|
+
- **Parameter sprawl**: piling new parameters onto a function instead of restructuring
|
|
34
|
+
- **Copy-paste with variation**: near-duplicate blocks that should share an abstraction
|
|
35
|
+
- **Leaky abstractions**: exposing internals or breaking existing boundaries
|
|
36
|
+
- **Stringly-typed code**: raw strings where an existing constant/enum/union exists
|
|
37
|
+
- **Unnecessary wrapper elements**: JSX/DOM wrappers that add no layout value
|
|
38
|
+
- **Nested conditionals**: ternary chains or if/else nested 3+ levels deep — flatten with early returns or a lookup table
|
|
39
|
+
- **Useless comments**: comments restating WHAT the code does, narrating the change, or referencing the task/caller — keep only non-obvious WHY
|
|
40
|
+
|
|
41
|
+
### C. Efficiency (Confirm)
|
|
42
|
+
|
|
43
|
+
- **Unnecessary work**: redundant computations, repeated file reads, duplicate API calls, N+1 patterns
|
|
44
|
+
- **Missed concurrency**: independent operations run sequentially when they could run in parallel
|
|
45
|
+
- **Hot-path bloat**: blocking work added to startup or per-request/per-render hot paths
|
|
46
|
+
- **Recurring no-op updates**: state writes inside loops/intervals/handlers that fire unconditionally — add a change-detection guard
|
|
47
|
+
- **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU) — operate directly and handle the error
|
|
48
|
+
- **Memory**: unbounded data structures, missing cleanup, event listener leaks
|
|
49
|
+
- **Overly broad operations**: reading whole files when a slice would do, loading all items when filtering for one
|
|
50
|
+
|
|
51
|
+
## How to Analyze
|
|
52
|
+
|
|
53
|
+
1. Unless the user explicitly asks for a folder/snapshot review, run \`git diff\` to see what changed. For folder/snapshot review, read the files directly under the requested paths and ignore files outside that scope.
|
|
54
|
+
2. For each change or in-scope snapshot finding, classify as **Essential** / **Residual** / **Legacy** (don't flag legacy code unless the diff touches it; in folder/snapshot mode, flag only in-scope code that is clearly removable or simplifiable).
|
|
55
|
+
3. For each finding:
|
|
56
|
+
- Identify exact file and line(s)
|
|
57
|
+
- Assign **category** (reuse / quality / efficiency) and **risk**
|
|
58
|
+
- State the concrete fix (which existing utility to call, which lines to delete, how to parallelize, etc.)
|
|
59
|
+
|
|
60
|
+
## Risk Levels
|
|
61
|
+
|
|
62
|
+
- **safe**: Definitely apply (dead code, debug remnants)
|
|
63
|
+
- **confirm**: Apply after user confirms (reuse swaps, over-engineering, hacky patterns, efficiency fixes)
|
|
64
|
+
- **review**: User should look first (commented-out code, ambiguous cases)
|
|
65
|
+
|
|
66
|
+
## Rules
|
|
67
|
+
|
|
68
|
+
1. When in doubt, mark as "confirm" or "review" — don't change without consent.
|
|
69
|
+
2. For reuse findings, name the existing symbol/file you'd swap to.
|
|
70
|
+
3. For efficiency findings, briefly justify the win (e.g., "N+1 → single query", "sequential awaits → Promise.all").
|
|
71
|
+
4. Don't flag necessary code just because it's simple.
|
|
72
|
+
5. Respect existing abstraction boundaries.
|
|
73
|
+
6. Be especially careful with:
|
|
74
|
+
- Error handling code
|
|
75
|
+
- Security-related logic
|
|
76
|
+
- Code that looks "unused" but is called via reflection/eval
|
|
77
|
+
- Database migration files
|
|
78
|
+
|
|
79
|
+
## Output Format (REQUIRED)
|
|
80
|
+
|
|
81
|
+
When analysis is complete, call the \`simplify_candidates\` tool exactly once as your final action. Put ALL candidates in that tool call.
|
|
82
|
+
|
|
83
|
+
Field notes:
|
|
84
|
+
- \`category\` — one of: "reuse", "quality", "efficiency"
|
|
85
|
+
- \`risk\` — one of: "safe", "confirm", "review"
|
|
86
|
+
- \`file\` — repository-relative path, no backticks, no markdown
|
|
87
|
+
- \`lines\` — line number or range ("42" or "42-57"); empty string if unknown
|
|
88
|
+
- \`reason\` — single plain-text sentence stating the concrete fix
|
|
89
|
+
- \`action\` — one of: "delete", "inline", "refactor", "parallelize"
|
|
90
|
+
|
|
91
|
+
If there are no candidates, call \`simplify_candidates\` with an empty \`candidates\` array.
|
|
92
|
+
Do NOT write a prose-only final answer; the extension relies on the tool result.
|
|
93
|
+
`;
|
package/selector.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { getSelectListTheme, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
matchesKey,
|
|
4
|
+
Key,
|
|
5
|
+
SelectList,
|
|
6
|
+
wrapTextWithAnsi,
|
|
7
|
+
type SelectItem,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
import type { Action, SimplifyResult } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const RISK_CONFIG = {
|
|
13
|
+
safe: { label: "Safe to apply" },
|
|
14
|
+
confirm: { label: "Needs confirmation" },
|
|
15
|
+
review: { label: "Needs review" },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const ACTION_LABEL: Record<Action, string> = {
|
|
19
|
+
delete: "Delete code",
|
|
20
|
+
inline: "Inline code",
|
|
21
|
+
refactor: "Refactor code",
|
|
22
|
+
parallelize: "Run work in parallel",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function showCandidateSelector(
|
|
26
|
+
ctx: ExtensionCommandContext,
|
|
27
|
+
candidates: SimplifyResult[],
|
|
28
|
+
): Promise<SimplifyResult[]> {
|
|
29
|
+
if (candidates.length === 0) return [];
|
|
30
|
+
|
|
31
|
+
const safeCandidates = candidates.filter((c) => c.risk === "safe");
|
|
32
|
+
const confirmCandidates = candidates.filter((c) => c.risk === "confirm");
|
|
33
|
+
const reviewCandidates = candidates.filter((c) => c.risk === "review");
|
|
34
|
+
|
|
35
|
+
const sections = [
|
|
36
|
+
{ key: "safe" as const, items: safeCandidates },
|
|
37
|
+
{ key: "confirm" as const, items: confirmCandidates },
|
|
38
|
+
{ key: "review" as const, items: reviewCandidates },
|
|
39
|
+
]
|
|
40
|
+
.map(({ key, items }) => ({ key, items, config: RISK_CONFIG[key], total: items.length }))
|
|
41
|
+
.filter((s) => s.total > 0);
|
|
42
|
+
|
|
43
|
+
const selectableItems = sections.flatMap((section) =>
|
|
44
|
+
section.items.map((candidate) => ({ section, candidate })),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const listItems: SelectItem[] = selectableItems.map(({ section, candidate }, index) => ({
|
|
48
|
+
value: String(index),
|
|
49
|
+
label: "",
|
|
50
|
+
description: `${section.config.label} · ${ACTION_LABEL[candidate.action]}`,
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const selectedSet = new Set<number>();
|
|
54
|
+
selectableItems.forEach(({ candidate }, index) => {
|
|
55
|
+
if (candidate.risk === "safe") selectedSet.add(index);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const maxVisible = 8;
|
|
59
|
+
let cursorPos = 0;
|
|
60
|
+
|
|
61
|
+
const getSelectListStartIndex = (selectedIndex: number) =>
|
|
62
|
+
Math.max(
|
|
63
|
+
0,
|
|
64
|
+
Math.min(selectedIndex - Math.floor(maxVisible / 2), selectableItems.length - maxVisible),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const result = await ctx.ui.custom<SimplifyResult[]>((tui, theme, _keybindings, done) => {
|
|
68
|
+
const updateListLabel = (index: number) => {
|
|
69
|
+
const item = listItems[index];
|
|
70
|
+
const { candidate } = selectableItems[index];
|
|
71
|
+
const checkbox = selectedSet.has(index) ? "[x]" : "[ ]";
|
|
72
|
+
const location = candidate.lines ? `${candidate.file}:${candidate.lines}` : candidate.file;
|
|
73
|
+
item.label = `${checkbox} ${location}`;
|
|
74
|
+
};
|
|
75
|
+
const updateAllListLabels = () => listItems.forEach((_item, index) => updateListLabel(index));
|
|
76
|
+
|
|
77
|
+
updateAllListLabels();
|
|
78
|
+
|
|
79
|
+
const selectList = new SelectList(listItems, maxVisible, getSelectListTheme());
|
|
80
|
+
selectList.onSelectionChange = (item) => {
|
|
81
|
+
cursorPos = Number(item.value);
|
|
82
|
+
};
|
|
83
|
+
selectList.onCancel = () => done([]);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
render(width: number) {
|
|
87
|
+
selectList.setSelectedIndex(cursorPos);
|
|
88
|
+
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
lines.push(theme.bold("Simplify: Select findings to apply"));
|
|
91
|
+
lines.push(
|
|
92
|
+
theme.fg(
|
|
93
|
+
"muted",
|
|
94
|
+
`Found ${candidates.length} candidates (${safeCandidates.length} safe, ${confirmCandidates.length} confirm, ${reviewCandidates.length} review)`,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
lines.push("");
|
|
98
|
+
|
|
99
|
+
const listLines = selectList.render(width);
|
|
100
|
+
const selectedLineIndex = cursorPos - getSelectListStartIndex(cursorPos);
|
|
101
|
+
const current = selectableItems[cursorPos]?.candidate;
|
|
102
|
+
for (const [lineIndex, line] of listLines.entries()) {
|
|
103
|
+
lines.push(line);
|
|
104
|
+
if (lineIndex !== selectedLineIndex || !current) continue;
|
|
105
|
+
|
|
106
|
+
const detailIndent = " ";
|
|
107
|
+
const detailPrefix = `${detailIndent}│ `;
|
|
108
|
+
const location = current.lines ? `${current.file}:${current.lines}` : current.file;
|
|
109
|
+
lines.push(theme.fg("borderMuted", `${detailIndent}┌─ recommendation`));
|
|
110
|
+
lines.push(
|
|
111
|
+
...wrapTextWithAnsi(current.reason, Math.max(1, width - detailPrefix.length)).map(
|
|
112
|
+
(wrappedLine) => theme.fg("muted", `${detailPrefix}${wrappedLine}`),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
lines.push(theme.fg("muted", `${detailPrefix}`));
|
|
116
|
+
lines.push(theme.fg("muted", `${detailPrefix}File: ${location}`));
|
|
117
|
+
lines.push(
|
|
118
|
+
theme.fg("muted", `${detailPrefix}Review: ${RISK_CONFIG[current.risk].label}`),
|
|
119
|
+
);
|
|
120
|
+
lines.push(theme.fg("muted", `${detailPrefix}Kind: ${current.category}`));
|
|
121
|
+
lines.push(theme.fg("muted", `${detailPrefix}Fix: ${ACTION_LABEL[current.action]}`));
|
|
122
|
+
lines.push(theme.fg("borderMuted", `${detailIndent}└─ space selects this fix`));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push(
|
|
127
|
+
theme.fg(
|
|
128
|
+
"muted",
|
|
129
|
+
`Selected: ${selectedSet.size} • ↑↓ navigate • space toggle • a select all • enter confirm • esc cancel`,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
return lines;
|
|
133
|
+
},
|
|
134
|
+
invalidate() {
|
|
135
|
+
selectList.invalidate();
|
|
136
|
+
},
|
|
137
|
+
handleInput(data: string) {
|
|
138
|
+
if (matchesKey(data, Key.enter)) {
|
|
139
|
+
const results = Array.from(selectedSet).map((i) => selectableItems[i].candidate);
|
|
140
|
+
done(results);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (matchesKey(data, Key.escape)) {
|
|
144
|
+
done([]);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (matchesKey(data, Key.space)) {
|
|
148
|
+
if (selectedSet.has(cursorPos)) {
|
|
149
|
+
selectedSet.delete(cursorPos);
|
|
150
|
+
} else {
|
|
151
|
+
selectedSet.add(cursorPos);
|
|
152
|
+
}
|
|
153
|
+
updateListLabel(cursorPos);
|
|
154
|
+
tui.requestRender();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (matchesKey(data, "a")) {
|
|
158
|
+
if (selectedSet.size === selectableItems.length) {
|
|
159
|
+
selectedSet.clear();
|
|
160
|
+
} else {
|
|
161
|
+
for (let i = 0; i < selectableItems.length; i++) selectedSet.add(i);
|
|
162
|
+
}
|
|
163
|
+
updateAllListLabels();
|
|
164
|
+
tui.requestRender();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const previousCursorPos = cursorPos;
|
|
169
|
+
selectList.handleInput(data);
|
|
170
|
+
if (cursorPos !== previousCursorPos) tui.requestRender();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return result ?? [];
|
|
176
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Category = "reuse" | "quality" | "efficiency";
|
|
2
|
+
export type Risk = "safe" | "confirm" | "review";
|
|
3
|
+
export type Action = "delete" | "inline" | "refactor" | "parallelize";
|
|
4
|
+
|
|
5
|
+
export type SimplifyResult = {
|
|
6
|
+
category: Category;
|
|
7
|
+
file: string;
|
|
8
|
+
lines: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
risk: Risk;
|
|
11
|
+
action: Action;
|
|
12
|
+
};
|
package/workflow.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineTool,
|
|
3
|
+
DynamicBorder,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionCommandContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
import { SIMPLIFY_PROMPT } from "./prompt.js";
|
|
11
|
+
import { showCandidateSelector } from "./selector.js";
|
|
12
|
+
import type { SimplifyResult } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const categorySchema = Type.Union([
|
|
15
|
+
Type.Literal("reuse"),
|
|
16
|
+
Type.Literal("quality"),
|
|
17
|
+
Type.Literal("efficiency"),
|
|
18
|
+
]);
|
|
19
|
+
const riskSchema = Type.Union([
|
|
20
|
+
Type.Literal("safe"),
|
|
21
|
+
Type.Literal("confirm"),
|
|
22
|
+
Type.Literal("review"),
|
|
23
|
+
]);
|
|
24
|
+
const actionSchema = Type.Union([
|
|
25
|
+
Type.Literal("delete"),
|
|
26
|
+
Type.Literal("inline"),
|
|
27
|
+
Type.Literal("refactor"),
|
|
28
|
+
Type.Literal("parallelize"),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
type SimplifyTarget = { type: "uncommitted" } | { type: "folder"; paths: string[] };
|
|
32
|
+
type ParsedArgs = {
|
|
33
|
+
target: SimplifyTarget | null;
|
|
34
|
+
additionalFocus?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const SIMPLIFY_PRESETS = [
|
|
39
|
+
{ value: "uncommitted", label: "Simplify uncommitted changes", description: "" },
|
|
40
|
+
{ value: "folder", label: "Simplify a folder (or more)", description: "(snapshot, not diff)" },
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
type SimplifyPresetValue = (typeof SIMPLIFY_PRESETS)[number]["value"];
|
|
44
|
+
|
|
45
|
+
function parseSimplifyPaths(value: string): string[] {
|
|
46
|
+
return value
|
|
47
|
+
.split(/\s+/)
|
|
48
|
+
.map((item) => item.trim())
|
|
49
|
+
.filter((item) => item.length > 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseArgs(args: string): ParsedArgs {
|
|
53
|
+
const trimmed = args.trim();
|
|
54
|
+
if (!trimmed) return { target: null };
|
|
55
|
+
|
|
56
|
+
const [subcommand, ...rest] = trimmed.split(/\s+/);
|
|
57
|
+
if (subcommand?.toLowerCase() !== "folder") {
|
|
58
|
+
return { target: { type: "uncommitted" }, additionalFocus: trimmed };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const paths = parseSimplifyPaths(rest.join(" "));
|
|
62
|
+
if (paths.length === 0)
|
|
63
|
+
return { target: null, error: "Usage: /simplify folder <path> [path...]" };
|
|
64
|
+
return { target: { type: "folder", paths } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
68
|
+
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
69
|
+
return code === 0 && stdout.trim().length > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildSimplifyPrompt(target: SimplifyTarget, additionalFocus?: string): string {
|
|
73
|
+
let fullPrompt = SIMPLIFY_PROMPT;
|
|
74
|
+
|
|
75
|
+
if (target.type === "folder") {
|
|
76
|
+
fullPrompt += `\n\n## Scope Override: Folder Snapshot\n\nReview only the code in these repository-relative paths: ${target.paths.join(", ")}\n\nThis is a snapshot review, not a git diff review. Read files directly under these paths instead of relying on \`git diff\`. Only return candidates whose \`file\` is inside one of these paths.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (additionalFocus?.trim()) fullPrompt += `\n\n## Additional Focus\n\n${additionalFocus.trim()}`;
|
|
80
|
+
return fullPrompt;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function registerSimplifyCandidatesTool(
|
|
84
|
+
pi: ExtensionAPI,
|
|
85
|
+
state: {
|
|
86
|
+
getResolver: () => ((candidates: SimplifyResult[]) => void) | null;
|
|
87
|
+
clearResolver: () => void;
|
|
88
|
+
setLatestCandidates: (candidates: SimplifyResult[]) => void;
|
|
89
|
+
},
|
|
90
|
+
) {
|
|
91
|
+
const parameters = Type.Object({
|
|
92
|
+
candidates: Type.Array(
|
|
93
|
+
Type.Object({
|
|
94
|
+
category: categorySchema,
|
|
95
|
+
risk: riskSchema,
|
|
96
|
+
file: Type.String({
|
|
97
|
+
description: "Repository-relative path",
|
|
98
|
+
minLength: 1,
|
|
99
|
+
pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$",
|
|
100
|
+
}),
|
|
101
|
+
lines: Type.String({ description: "Line number or range, or empty string if unknown" }),
|
|
102
|
+
reason: Type.String({ description: "Concrete fix in one sentence" }),
|
|
103
|
+
action: actionSchema,
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
pi.registerTool(
|
|
109
|
+
defineTool({
|
|
110
|
+
name: "simplify_candidates",
|
|
111
|
+
label: "Simplify Candidates",
|
|
112
|
+
description:
|
|
113
|
+
"Return the complete candidate list for /simplify. Use as the final action after analyzing changed code.",
|
|
114
|
+
promptSnippet: "Return structured cleanup candidates for /simplify as a terminating result",
|
|
115
|
+
promptGuidelines: [
|
|
116
|
+
"Use simplify_candidates exactly once as the final action when the /simplify command asks for cleanup candidates.",
|
|
117
|
+
],
|
|
118
|
+
parameters,
|
|
119
|
+
async execute(_toolCallId, params) {
|
|
120
|
+
const candidates: SimplifyResult[] = params.candidates;
|
|
121
|
+
const resolve = state.getResolver();
|
|
122
|
+
if (!resolve) {
|
|
123
|
+
return {
|
|
124
|
+
content: [
|
|
125
|
+
{
|
|
126
|
+
type: "text" as const,
|
|
127
|
+
text: "Ignored simplify candidates because no /simplify analysis is pending.",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
details: {
|
|
131
|
+
ignored: true,
|
|
132
|
+
candidates: [],
|
|
133
|
+
byRisk: { safe: 0, confirm: 0, review: 0 },
|
|
134
|
+
},
|
|
135
|
+
terminate: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
state.setLatestCandidates(candidates);
|
|
140
|
+
state.clearResolver();
|
|
141
|
+
resolve(candidates);
|
|
142
|
+
|
|
143
|
+
const byRisk = {
|
|
144
|
+
safe: candidates.filter((c) => c.risk === "safe").length,
|
|
145
|
+
confirm: candidates.filter((c) => c.risk === "confirm").length,
|
|
146
|
+
review: candidates.filter((c) => c.risk === "review").length,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
type: "text" as const,
|
|
153
|
+
text: `Captured ${candidates.length} simplify candidates (${byRisk.safe} safe, ${byRisk.confirm} confirm, ${byRisk.review} review).`,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
details: { ignored: false, candidates, byRisk },
|
|
157
|
+
terminate: true,
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function showFolderInput(ctx: ExtensionCommandContext): Promise<SimplifyTarget | null> {
|
|
165
|
+
const result = await ctx.ui.editor(
|
|
166
|
+
"Enter folders/files to simplify (space-separated or one per line):",
|
|
167
|
+
".",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (!result?.trim()) return null;
|
|
171
|
+
const paths = parseSimplifyPaths(result);
|
|
172
|
+
if (paths.length === 0) return null;
|
|
173
|
+
|
|
174
|
+
return { type: "folder", paths };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function showSimplifyTargetSelector(
|
|
178
|
+
ctx: ExtensionCommandContext,
|
|
179
|
+
pi: ExtensionAPI,
|
|
180
|
+
): Promise<SimplifyTarget | null> {
|
|
181
|
+
const presetItems: SelectItem[] = SIMPLIFY_PRESETS.map((preset) => ({
|
|
182
|
+
value: preset.value,
|
|
183
|
+
label: preset.label,
|
|
184
|
+
description: preset.description,
|
|
185
|
+
}));
|
|
186
|
+
const smartDefault = (await hasUncommittedChanges(pi)) ? "uncommitted" : "folder";
|
|
187
|
+
const smartDefaultIndex = presetItems.findIndex((item) => item.value === smartDefault);
|
|
188
|
+
|
|
189
|
+
while (true) {
|
|
190
|
+
const result = await ctx.ui.custom<SimplifyPresetValue | null>((tui, theme, _kb, done) => {
|
|
191
|
+
const container = new Container();
|
|
192
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
193
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select a simplify preset"))));
|
|
194
|
+
|
|
195
|
+
const selectList = new SelectList(presetItems, Math.min(presetItems.length, 10), {
|
|
196
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
197
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
198
|
+
description: (text) => theme.fg("muted", text),
|
|
199
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
200
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (smartDefaultIndex >= 0) selectList.setSelectedIndex(smartDefaultIndex);
|
|
204
|
+
selectList.onSelect = (item) => done(item.value as SimplifyPresetValue);
|
|
205
|
+
selectList.onCancel = () => done(null);
|
|
206
|
+
|
|
207
|
+
container.addChild(selectList);
|
|
208
|
+
container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to cancel")));
|
|
209
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
render(width: number) {
|
|
213
|
+
return container.render(width);
|
|
214
|
+
},
|
|
215
|
+
invalidate() {
|
|
216
|
+
container.invalidate();
|
|
217
|
+
},
|
|
218
|
+
handleInput(data: string) {
|
|
219
|
+
selectList.handleInput(data);
|
|
220
|
+
tui.requestRender();
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (!result) return null;
|
|
226
|
+
if (result === "uncommitted") return { type: "uncommitted" };
|
|
227
|
+
|
|
228
|
+
const target = await showFolderInput(ctx);
|
|
229
|
+
if (target) return target;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function applyFindings(
|
|
234
|
+
ctx: ExtensionCommandContext,
|
|
235
|
+
selected: SimplifyResult[],
|
|
236
|
+
pi: ExtensionAPI,
|
|
237
|
+
) {
|
|
238
|
+
if (selected.length === 0) {
|
|
239
|
+
ctx.ui.notify("No findings selected to apply", "info");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const safeItems = selected.filter((c) => c.risk === "safe");
|
|
244
|
+
const confirmItems = selected.filter((c) => c.risk === "confirm");
|
|
245
|
+
|
|
246
|
+
const cleanupPrompt = `# Apply Review Findings
|
|
247
|
+
|
|
248
|
+
Apply the following findings. Each item includes a bracketed action (e.g. \`[delete]\`, \`[refactor]\`, \`[parallelize]\`, \`[inline]\`) — follow that action, not a blanket delete.
|
|
249
|
+
|
|
250
|
+
${selected.map((c) => `- ${c.file} (${c.lines || "?"}): ${c.reason} [${c.action}]`).join("\n")}
|
|
251
|
+
|
|
252
|
+
For each item:
|
|
253
|
+
1. Read the file to find the exact location
|
|
254
|
+
2. Apply only the specified change (not surrounding code unless instructed)
|
|
255
|
+
3. For refactor/inline/parallelize: preserve behavior; if the change has visible side effects, stop and report
|
|
256
|
+
4. If the change affects other code, stop and report the issue
|
|
257
|
+
|
|
258
|
+
After applying all changes:
|
|
259
|
+
- Run \`npm test\` or equivalent; if there are no tests, verify the files parse/type-check
|
|
260
|
+
- Report what changed, test results, and any items skipped with reasons`;
|
|
261
|
+
|
|
262
|
+
ctx.ui.notify(
|
|
263
|
+
`Applying ${selected.length} findings (${safeItems.length} safe, ${confirmItems.length} confirmed)`,
|
|
264
|
+
"info",
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
pi.sendUserMessage(cleanupPrompt);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function registerSimplifyWorkflow(pi: ExtensionAPI) {
|
|
271
|
+
let resolvePendingTurnStart: (() => void) | null = null;
|
|
272
|
+
let resolvePendingCandidatesTool: ((candidates: SimplifyResult[]) => void) | null = null;
|
|
273
|
+
let latestToolCandidates: SimplifyResult[] | null = null;
|
|
274
|
+
|
|
275
|
+
pi.on("turn_start", () => {
|
|
276
|
+
if (!resolvePendingTurnStart) return;
|
|
277
|
+
const resolve = resolvePendingTurnStart;
|
|
278
|
+
resolvePendingTurnStart = null;
|
|
279
|
+
resolve();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
registerSimplifyCandidatesTool(pi, {
|
|
283
|
+
getResolver: () => resolvePendingCandidatesTool,
|
|
284
|
+
clearResolver: () => {
|
|
285
|
+
resolvePendingCandidatesTool = null;
|
|
286
|
+
},
|
|
287
|
+
setLatestCandidates: (candidates) => {
|
|
288
|
+
latestToolCandidates = candidates;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
pi.registerCommand("simplify", {
|
|
293
|
+
description:
|
|
294
|
+
"Review changed code or folders for reuse, quality, and efficiency, then apply fixes",
|
|
295
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
296
|
+
if (!ctx.hasUI) {
|
|
297
|
+
ctx.ui.notify("Simplify requires interactive mode", "error");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!ctx.isIdle()) {
|
|
302
|
+
ctx.ui.notify(
|
|
303
|
+
"Cannot run simplify while agent is busy. Wait for current task to complete.",
|
|
304
|
+
"warning",
|
|
305
|
+
);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
310
|
+
if (code !== 0) {
|
|
311
|
+
ctx.ui.notify("Not a git repository", "error");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const parsed = parseArgs(args);
|
|
316
|
+
if (parsed.error) {
|
|
317
|
+
ctx.ui.notify(parsed.error, "error");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let target = parsed.target;
|
|
322
|
+
if (!target) target = await showSimplifyTargetSelector(ctx, pi);
|
|
323
|
+
if (!target) {
|
|
324
|
+
ctx.ui.notify("Simplify cancelled", "info");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (target.type === "uncommitted" && !(await hasUncommittedChanges(pi))) {
|
|
329
|
+
ctx.ui.notify("No git changes found. Choose folder mode to simplify a snapshot.", "info");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const targetHint =
|
|
334
|
+
target.type === "folder" ? `folders: ${target.paths.join(", ")}` : "uncommitted changes";
|
|
335
|
+
ctx.ui.notify(`Analyzing ${targetHint} for review findings...`, "info");
|
|
336
|
+
|
|
337
|
+
const fullPrompt = buildSimplifyPrompt(target, parsed.additionalFocus);
|
|
338
|
+
|
|
339
|
+
latestToolCandidates = null;
|
|
340
|
+
const turnStarted = new Promise<void>((r) => {
|
|
341
|
+
resolvePendingTurnStart = r;
|
|
342
|
+
});
|
|
343
|
+
const toolResult = new Promise<SimplifyResult[]>((r) => {
|
|
344
|
+
resolvePendingCandidatesTool = r;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
pi.sendUserMessage(fullPrompt);
|
|
348
|
+
|
|
349
|
+
const started = await Promise.race<boolean>([
|
|
350
|
+
turnStarted.then(() => true),
|
|
351
|
+
new Promise<boolean>((r) => setTimeout(() => r(false), 10000)),
|
|
352
|
+
]);
|
|
353
|
+
if (!started) {
|
|
354
|
+
resolvePendingTurnStart = null;
|
|
355
|
+
resolvePendingCandidatesTool = null;
|
|
356
|
+
ctx.ui.notify("Analysis did not start within 10 seconds. Please try again.", "warning");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await ctx.waitForIdle();
|
|
361
|
+
|
|
362
|
+
const candidates =
|
|
363
|
+
latestToolCandidates ??
|
|
364
|
+
(await Promise.race<SimplifyResult[] | null>([
|
|
365
|
+
toolResult,
|
|
366
|
+
new Promise<null>((r) => setTimeout(() => r(null), 100)),
|
|
367
|
+
]));
|
|
368
|
+
resolvePendingCandidatesTool = null;
|
|
369
|
+
|
|
370
|
+
if (candidates === null) {
|
|
371
|
+
ctx.ui.notify(
|
|
372
|
+
"Could not read review findings — the model did not call simplify_candidates.",
|
|
373
|
+
"warning",
|
|
374
|
+
);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (candidates.length === 0) {
|
|
379
|
+
ctx.ui.notify("No review findings found!", "info");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
ctx.ui.notify(`Found ${candidates.length} findings. Select findings to apply...`, "info");
|
|
384
|
+
|
|
385
|
+
const selected = await showCandidateSelector(ctx, candidates);
|
|
386
|
+
if (!selected || selected.length === 0) {
|
|
387
|
+
ctx.ui.notify("Apply findings cancelled", "info");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
await applyFindings(ctx, selected, pi);
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
}
|