@ia-ccun/code-agent-cli 0.0.51-beta.1 → 0.0.51-beta.10
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/bin/cli.js +6 -5
- package/config/agent/extensions/aicode-plan.ts +897 -0
- package/config/agent/extensions/custom-footer.ts +19 -0
- package/config/agent/extensions/custom.ts +1 -4
- package/config/agent/extensions/goal.ts +450 -0
- package/config/agent/help.md +2 -0
- package/dist/config-loader.d.ts +2 -0
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/config-loader.js +81 -7
- package/dist/config-loader.js.map +1 -1
- package/dist/security-logger.d.ts +69 -0
- package/dist/security-logger.d.ts.map +1 -0
- package/dist/security-logger.js +178 -0
- package/dist/security-logger.js.map +1 -0
- package/package.json +12 -2
- package/scripts/postinstall.js +126 -46
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Spacer, Text, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
const STATUS_KEY = "aicode-plan";
|
|
5
|
+
const TODO_WIDGET_KEY = "aicode-plan-todos";
|
|
6
|
+
const CUSTOM_TYPE = "aicode-plan-event";
|
|
7
|
+
|
|
8
|
+
type PlanNextAction = "approve" | "continue" | "regenerate" | "exit";
|
|
9
|
+
|
|
10
|
+
interface PlanNextActionResult {
|
|
11
|
+
cancelled: boolean;
|
|
12
|
+
action?: PlanNextAction;
|
|
13
|
+
continueNote?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TodoItem {
|
|
17
|
+
step: number;
|
|
18
|
+
text: string;
|
|
19
|
+
completed: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PLAN_TOOL_CANDIDATES = [
|
|
23
|
+
"read",
|
|
24
|
+
"bash",
|
|
25
|
+
"grep",
|
|
26
|
+
"find",
|
|
27
|
+
"ls",
|
|
28
|
+
"lsp",
|
|
29
|
+
"ast_search",
|
|
30
|
+
"web_search",
|
|
31
|
+
"fetch_content",
|
|
32
|
+
"get_search_content",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const WRITE_LIKE_TOOLS = new Set(["edit", "write", "ast_rewrite"]);
|
|
36
|
+
|
|
37
|
+
const PLAN_MODE_SYSTEM_PROMPT = `
|
|
38
|
+
[计划模式已激活 - 仅限只读]
|
|
39
|
+
你当前处于计划模式。
|
|
40
|
+
|
|
41
|
+
硬性规则:
|
|
42
|
+
- 允许的操作:仅限检查、分析和创建计划
|
|
43
|
+
- 绝不执行任何写入/变更操作
|
|
44
|
+
- 绝不使用 edit/write 或变更性 shell 命令
|
|
45
|
+
|
|
46
|
+
强制工作流程:
|
|
47
|
+
1) 首先收集上下文
|
|
48
|
+
- 在提出计划前检查相关文件/路径/符号/配置/测试
|
|
49
|
+
- 如果涉及外部依赖行为,收集官方文档/参考证据
|
|
50
|
+
- 不允许无证据的规划
|
|
51
|
+
2) 需求澄清
|
|
52
|
+
- 明确列出不确定性/假设
|
|
53
|
+
- 如果有阻塞性模糊点,在最终确定前提出简洁的澄清问题
|
|
54
|
+
3) 计划设计
|
|
55
|
+
- 基于收集的证据构建具体的执行计划
|
|
56
|
+
|
|
57
|
+
输出规范(使用此结构):
|
|
58
|
+
1) 目标理解(简洁)
|
|
59
|
+
2) 收集的证据
|
|
60
|
+
- 检查过的文件/路径/符号/文档
|
|
61
|
+
3) 不确定性 / 假设
|
|
62
|
+
4) 计划:
|
|
63
|
+
1. 步骤目标
|
|
64
|
+
2. 目标文件/组件
|
|
65
|
+
3. 验证方法
|
|
66
|
+
5) 风险和回滚说明
|
|
67
|
+
6) 结尾:"准备就绪,等待批准后执行。"
|
|
68
|
+
`.trim();
|
|
69
|
+
|
|
70
|
+
const YOLO_MODE_SYSTEM_PROMPT = `
|
|
71
|
+
[默认模式:直接执行]
|
|
72
|
+
- 直接执行任务,除非用户明确要求规划
|
|
73
|
+
- 在正常模式下不要强制执行计划/批准流程
|
|
74
|
+
- 只读计划/批准流程仅在 /plan 模式启用时激活
|
|
75
|
+
`.trim();
|
|
76
|
+
|
|
77
|
+
const EXECUTION_TRIGGER_PROMPT =
|
|
78
|
+
"计划已批准。现在切换到执行模式并实施最新计划。";
|
|
79
|
+
|
|
80
|
+
// ============== 工具函数 ==============
|
|
81
|
+
|
|
82
|
+
function isSafeReadOnlyCommand(command: string): boolean {
|
|
83
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
84
|
+
/\brm\b/i,
|
|
85
|
+
/\brmdir\b/i,
|
|
86
|
+
/\bmv\b/i,
|
|
87
|
+
/\bcp\b/i,
|
|
88
|
+
/\bmkdir\b/i,
|
|
89
|
+
/\btouch\b/i,
|
|
90
|
+
/\bchmod\b/i,
|
|
91
|
+
/\bchown\b/i,
|
|
92
|
+
/\bchgrp\b/i,
|
|
93
|
+
/\bln\b/i,
|
|
94
|
+
/\btee\b/i,
|
|
95
|
+
/\btruncate\b/i,
|
|
96
|
+
/\bdd\b/i,
|
|
97
|
+
/\bshred\b/i,
|
|
98
|
+
/(^|[^<])>(?!>)/,
|
|
99
|
+
/>>/,
|
|
100
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish)\b/i,
|
|
101
|
+
/\byarn\s+(add|remove|install|publish)\b/i,
|
|
102
|
+
/\bpnpm\s+(add|remove|install|publish)\b/i,
|
|
103
|
+
/\bpip\s+(install|uninstall)\b/i,
|
|
104
|
+
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)\b/i,
|
|
105
|
+
/\bbrew\s+(install|uninstall|upgrade)\b/i,
|
|
106
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)\b/i,
|
|
107
|
+
/\bsudo\b/i,
|
|
108
|
+
/\bsu\b/i,
|
|
109
|
+
/\bkill\b/i,
|
|
110
|
+
/\bpkill\b/i,
|
|
111
|
+
/\bkillall\b/i,
|
|
112
|
+
/\breboot\b/i,
|
|
113
|
+
/\bshutdown\b/i,
|
|
114
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)\b/i,
|
|
115
|
+
/\bservice\s+\S+\s+(start|stop|restart)\b/i,
|
|
116
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const SAFE_PATTERNS = [
|
|
120
|
+
/^\s*cat\b/,
|
|
121
|
+
/^\s*head\b/,
|
|
122
|
+
/^\s*tail\b/,
|
|
123
|
+
/^\s*less\b/,
|
|
124
|
+
/^\s*more\b/,
|
|
125
|
+
/^\s*grep\b/,
|
|
126
|
+
/^\s*find\b/,
|
|
127
|
+
/^\s*ls\b/,
|
|
128
|
+
/^\s*pwd\b/,
|
|
129
|
+
/^\s*echo\b/,
|
|
130
|
+
/^\s*printf\b/,
|
|
131
|
+
/^\s*wc\b/,
|
|
132
|
+
/^\s*sort\b/,
|
|
133
|
+
/^\s*uniq\b/,
|
|
134
|
+
/^\s*diff\b/,
|
|
135
|
+
/^\s*file\b/,
|
|
136
|
+
/^\s*stat\b/,
|
|
137
|
+
/^\s*du\b/,
|
|
138
|
+
/^\s*df\b/,
|
|
139
|
+
/^\s*tree\b/,
|
|
140
|
+
/^\s*which\b/,
|
|
141
|
+
/^\s*whereis\b/,
|
|
142
|
+
/^\s*type\b/,
|
|
143
|
+
/^\s*env\b/,
|
|
144
|
+
/^\s*printenv\b/,
|
|
145
|
+
/^\s*uname\b/,
|
|
146
|
+
/^\s*whoami\b/,
|
|
147
|
+
/^\s*id\b/,
|
|
148
|
+
/^\s*date\b/,
|
|
149
|
+
/^\s*cal\b/,
|
|
150
|
+
/^\s*uptime\b/,
|
|
151
|
+
/^\s*ps\b/,
|
|
152
|
+
/^\s*top\b/,
|
|
153
|
+
/^\s*htop\b/,
|
|
154
|
+
/^\s*free\b/,
|
|
155
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)\b/i,
|
|
156
|
+
/^\s*git\s+ls-/i,
|
|
157
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
|
|
158
|
+
/^\s*yarn\s+(list|info|why|audit)\b/i,
|
|
159
|
+
/^\s*node\s+--version\b/i,
|
|
160
|
+
/^\s*python\s+--version\b/i,
|
|
161
|
+
/^\s*jq\b/,
|
|
162
|
+
/^\s*sed\s+-n\b/i,
|
|
163
|
+
/^\s*awk\b/,
|
|
164
|
+
/^\s*rg\b/,
|
|
165
|
+
/^\s*fd\b/,
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command));
|
|
169
|
+
if (isDestructive) return false;
|
|
170
|
+
return SAFE_PATTERNS.some((pattern) => pattern.test(command));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeArg(input: string): string {
|
|
174
|
+
return input.trim().toLowerCase();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cleanStepText(text: string): string {
|
|
178
|
+
let cleaned = text
|
|
179
|
+
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
|
|
180
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
181
|
+
.replace(
|
|
182
|
+
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install|使用 | 运行 | 执行 | 创建 | 写入 | 读取 | 检查 | 验证 | 更新 | 修改 | 添加 | 删除 | 安装)\s+(the\s+)?/i,
|
|
183
|
+
"",
|
|
184
|
+
)
|
|
185
|
+
.replace(/\s+/g, " ")
|
|
186
|
+
.trim();
|
|
187
|
+
|
|
188
|
+
if (cleaned.length > 0) {
|
|
189
|
+
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
190
|
+
}
|
|
191
|
+
if (cleaned.length > 80) {
|
|
192
|
+
cleaned = `${cleaned.slice(0, 77)}...`;
|
|
193
|
+
}
|
|
194
|
+
return cleaned;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function extractTodoItems(message: string): TodoItem[] {
|
|
198
|
+
const items: TodoItem[] = [];
|
|
199
|
+
const headerMatch = message.match(/\*{0,2}(?:Plan|计划):\*{0,2}\s*\n/i);
|
|
200
|
+
if (!headerMatch) return items;
|
|
201
|
+
|
|
202
|
+
const planSection = message.slice(
|
|
203
|
+
message.indexOf(headerMatch[0]) + headerMatch[0].length,
|
|
204
|
+
);
|
|
205
|
+
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
|
206
|
+
|
|
207
|
+
for (const match of planSection.matchAll(numberedPattern)) {
|
|
208
|
+
const text = match[2]
|
|
209
|
+
.trim()
|
|
210
|
+
.replace(/\*{1,2}$/, "")
|
|
211
|
+
.trim();
|
|
212
|
+
if (
|
|
213
|
+
text.length > 5 &&
|
|
214
|
+
!text.startsWith("`") &&
|
|
215
|
+
!text.startsWith("/") &&
|
|
216
|
+
!text.startsWith("-")
|
|
217
|
+
) {
|
|
218
|
+
const cleaned = cleanStepText(text);
|
|
219
|
+
if (cleaned.length > 3) {
|
|
220
|
+
items.push({
|
|
221
|
+
step: items.length + 1,
|
|
222
|
+
text: cleaned,
|
|
223
|
+
completed: false,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return items;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function extractDoneSteps(message: string): number[] {
|
|
232
|
+
const steps: number[] = [];
|
|
233
|
+
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
|
234
|
+
const step = Number(match[1]);
|
|
235
|
+
if (Number.isFinite(step)) {
|
|
236
|
+
steps.push(step);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return steps;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
243
|
+
const doneSteps = extractDoneSteps(text);
|
|
244
|
+
for (const step of doneSteps) {
|
|
245
|
+
const item = items.find((candidate) => candidate.step === step);
|
|
246
|
+
if (item) {
|
|
247
|
+
item.completed = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return doneSteps.length;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getAssistantTextFromMessage(message: unknown): string {
|
|
254
|
+
const candidate = message as {
|
|
255
|
+
role?: unknown;
|
|
256
|
+
content?: unknown;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (candidate.role !== "assistant") {
|
|
260
|
+
return "";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (typeof candidate.content === "string") {
|
|
264
|
+
return candidate.content;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!Array.isArray(candidate.content)) {
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return candidate.content
|
|
272
|
+
.filter(
|
|
273
|
+
(block): block is { type?: string; text?: string } =>
|
|
274
|
+
typeof block === "object" &&
|
|
275
|
+
block !== null &&
|
|
276
|
+
(block as { type?: string }).type === "text" &&
|
|
277
|
+
typeof (block as { text?: string }).text === "string",
|
|
278
|
+
)
|
|
279
|
+
.map((block) => block.text ?? "")
|
|
280
|
+
.join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ============== UI 组件 ==============
|
|
284
|
+
|
|
285
|
+
const ACTION_OPTIONS: ReadonlyArray<{ label: string; value: PlanNextAction }> = [
|
|
286
|
+
{ label: "批准并立即执行", value: "approve" },
|
|
287
|
+
{ label: "从当前计划继续", value: "continue" },
|
|
288
|
+
{ label: "重新生成计划", value: "regenerate" },
|
|
289
|
+
{ label: "退出计划模式", value: "exit" },
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
const CONTINUE_OPTION_INDEX = ACTION_OPTIONS.findIndex((option) => option.value === "continue");
|
|
293
|
+
|
|
294
|
+
function normalizeContinueNote(input: string): string {
|
|
295
|
+
return input.replace(/\s+/g, " ").trim();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildContinueOptionLabel(
|
|
299
|
+
baseLabel: string,
|
|
300
|
+
note: string,
|
|
301
|
+
isEditing: boolean,
|
|
302
|
+
maxLength: number,
|
|
303
|
+
): string {
|
|
304
|
+
const normalized = normalizeContinueNote(note);
|
|
305
|
+
if (normalized.length === 0 && !isEditing) {
|
|
306
|
+
return baseLabel;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const suffix = isEditing ? `${normalized}▍` : normalized;
|
|
310
|
+
const inline = `${baseLabel} — 备注:${suffix}`;
|
|
311
|
+
if (inline.length <= maxLength) {
|
|
312
|
+
return inline;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (maxLength <= 1) {
|
|
316
|
+
return "…";
|
|
317
|
+
}
|
|
318
|
+
return `${inline.slice(0, maxLength - 1)}…`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function selectPlanNextActionWithInlineNote(
|
|
322
|
+
ui: ExtensionContext["ui"],
|
|
323
|
+
): Promise<PlanNextActionResult> {
|
|
324
|
+
return ui.custom<PlanNextActionResult>((tui, theme, _keybindings, done) => {
|
|
325
|
+
let cursorIndex = 0;
|
|
326
|
+
let isContinueNoteEditorOpen = false;
|
|
327
|
+
let continueNote = "";
|
|
328
|
+
let cachedRenderedLines: string[] | undefined;
|
|
329
|
+
|
|
330
|
+
const editorTheme: EditorTheme = {
|
|
331
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
332
|
+
selectList: {
|
|
333
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
334
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
335
|
+
description: (text) => theme.fg("muted", text),
|
|
336
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
337
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
const noteEditor = new Editor(tui, editorTheme);
|
|
341
|
+
|
|
342
|
+
const requestUiRerender = () => {
|
|
343
|
+
cachedRenderedLines = undefined;
|
|
344
|
+
tui.requestRender();
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const getNormalizedContinueNote = (): string => normalizeContinueNote(continueNote);
|
|
348
|
+
|
|
349
|
+
const openContinueEditor = () => {
|
|
350
|
+
if (cursorIndex !== CONTINUE_OPTION_INDEX) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
isContinueNoteEditorOpen = true;
|
|
354
|
+
noteEditor.setText(continueNote);
|
|
355
|
+
requestUiRerender();
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
noteEditor.onChange = (value) => {
|
|
359
|
+
continueNote = value;
|
|
360
|
+
requestUiRerender();
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
noteEditor.onSubmit = (value) => {
|
|
364
|
+
continueNote = value;
|
|
365
|
+
const normalized = getNormalizedContinueNote();
|
|
366
|
+
if (normalized.length === 0) {
|
|
367
|
+
isContinueNoteEditorOpen = false;
|
|
368
|
+
requestUiRerender();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
done({
|
|
373
|
+
cancelled: false,
|
|
374
|
+
action: "continue",
|
|
375
|
+
continueNote: normalized,
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const render = (width: number): string[] => {
|
|
380
|
+
if (cachedRenderedLines) {
|
|
381
|
+
return cachedRenderedLines;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const renderedLines: string[] = [];
|
|
385
|
+
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
|
|
386
|
+
|
|
387
|
+
addLine(theme.fg("accent", "─".repeat(width)));
|
|
388
|
+
addLine(theme.fg("text", " 计划模式:下一步操作"));
|
|
389
|
+
renderedLines.push("");
|
|
390
|
+
|
|
391
|
+
const maxInlineLabelLength = Math.max(20, width - 8);
|
|
392
|
+
for (let optionIndex = 0; optionIndex < ACTION_OPTIONS.length; optionIndex++) {
|
|
393
|
+
const option = ACTION_OPTIONS[optionIndex];
|
|
394
|
+
const isCursorOption = optionIndex === cursorIndex;
|
|
395
|
+
const isContinueOption = optionIndex === CONTINUE_OPTION_INDEX;
|
|
396
|
+
const optionLabel = isContinueOption
|
|
397
|
+
? buildContinueOptionLabel(
|
|
398
|
+
option.label,
|
|
399
|
+
continueNote,
|
|
400
|
+
isContinueNoteEditorOpen && isCursorOption,
|
|
401
|
+
maxInlineLabelLength,
|
|
402
|
+
)
|
|
403
|
+
: option.label;
|
|
404
|
+
const cursorPrefix = isCursorOption ? theme.fg("accent", "→ ") : " ";
|
|
405
|
+
const bullet = isCursorOption ? "●" : "○";
|
|
406
|
+
const optionColor = isCursorOption ? "accent" : "text";
|
|
407
|
+
addLine(`${cursorPrefix}${theme.fg(optionColor, `${bullet} ${optionLabel}`)}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
renderedLines.push("");
|
|
411
|
+
if (isContinueNoteEditorOpen) {
|
|
412
|
+
addLine(theme.fg("dim", " 正在输入备注 • Enter 继续 • Tab/Esc 停止编辑"));
|
|
413
|
+
} else if (cursorIndex === CONTINUE_OPTION_INDEX) {
|
|
414
|
+
if (getNormalizedContinueNote().length > 0) {
|
|
415
|
+
addLine(theme.fg("dim", " ↑↓ 移动 • Enter 继续 • Tab 编辑备注 • Esc 取消"));
|
|
416
|
+
} else {
|
|
417
|
+
addLine(theme.fg("dim", " ↑↓ 移动 • Enter 继续 • Tab 添加备注 • Esc 取消"));
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
addLine(theme.fg("dim", " ↑↓ 移动 • Enter 选择 • Esc 取消"));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
addLine(theme.fg("accent", "─".repeat(width)));
|
|
424
|
+
cachedRenderedLines = renderedLines;
|
|
425
|
+
return renderedLines;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const handleInput = (data: string) => {
|
|
429
|
+
if (isContinueNoteEditorOpen) {
|
|
430
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
|
|
431
|
+
isContinueNoteEditorOpen = false;
|
|
432
|
+
requestUiRerender();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
noteEditor.handleInput(data);
|
|
436
|
+
requestUiRerender();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (matchesKey(data, Key.up)) {
|
|
441
|
+
cursorIndex = Math.max(0, cursorIndex - 1);
|
|
442
|
+
requestUiRerender();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (matchesKey(data, Key.down)) {
|
|
447
|
+
cursorIndex = Math.min(ACTION_OPTIONS.length - 1, cursorIndex + 1);
|
|
448
|
+
requestUiRerender();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (matchesKey(data, Key.tab)) {
|
|
453
|
+
if (cursorIndex === CONTINUE_OPTION_INDEX) {
|
|
454
|
+
openContinueEditor();
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (matchesKey(data, Key.enter)) {
|
|
460
|
+
const selected = ACTION_OPTIONS[cursorIndex];
|
|
461
|
+
if (selected.value === "continue") {
|
|
462
|
+
const normalized = getNormalizedContinueNote();
|
|
463
|
+
done({
|
|
464
|
+
cancelled: false,
|
|
465
|
+
action: "continue",
|
|
466
|
+
continueNote: normalized.length > 0 ? normalized : undefined,
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
done({ cancelled: false, action: selected.value });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (matchesKey(data, Key.escape)) {
|
|
476
|
+
done({ cancelled: true });
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
render,
|
|
482
|
+
invalidate: () => {
|
|
483
|
+
cachedRenderedLines = undefined;
|
|
484
|
+
},
|
|
485
|
+
handleInput,
|
|
486
|
+
};
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ============== 扩展主函数 ==============
|
|
491
|
+
|
|
492
|
+
export default function aicodePlan(pi: ExtensionAPI) {
|
|
493
|
+
let planModeEnabled = false;
|
|
494
|
+
let executionMode = false;
|
|
495
|
+
let restoreTools: string[] | null = null;
|
|
496
|
+
let todoItems: TodoItem[] = [];
|
|
497
|
+
|
|
498
|
+
const getAllToolNames = (): string[] => pi.getAllTools().map((tool) => tool.name);
|
|
499
|
+
|
|
500
|
+
const getPlanTools = (): string[] => {
|
|
501
|
+
const available = new Set(getAllToolNames());
|
|
502
|
+
const planTools = PLAN_TOOL_CANDIDATES.filter((tool) => available.has(tool));
|
|
503
|
+
if (planTools.length > 0) {
|
|
504
|
+
return [...planTools];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const fallback = pi.getActiveTools().filter((tool) => !WRITE_LIKE_TOOLS.has(tool));
|
|
508
|
+
return [...new Set(fallback)];
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const restoreNormalTools = (): void => {
|
|
512
|
+
const toolsToRestore =
|
|
513
|
+
restoreTools && restoreTools.length > 0
|
|
514
|
+
? [...restoreTools]
|
|
515
|
+
: [...getAllToolNames()];
|
|
516
|
+
if (toolsToRestore.length > 0) {
|
|
517
|
+
pi.setActiveTools(toolsToRestore);
|
|
518
|
+
}
|
|
519
|
+
restoreTools = null;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const updateTodoWidget = (ctx: ExtensionContext): void => {
|
|
523
|
+
if (!ctx.hasUI) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!executionMode || todoItems.length === 0) {
|
|
528
|
+
ctx.ui.setWidget(TODO_WIDGET_KEY, undefined);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const lines = todoItems.map((item) => {
|
|
533
|
+
if (item.completed) {
|
|
534
|
+
return (
|
|
535
|
+
ctx.ui.theme.fg("success", "✓ ") +
|
|
536
|
+
ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return `${ctx.ui.theme.fg("muted", "○ ")}${item.text}`;
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
ctx.ui.setWidget(TODO_WIDGET_KEY, lines);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const setStatus = (ctx: ExtensionContext): void => {
|
|
546
|
+
if (!ctx.hasUI) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (executionMode && todoItems.length > 0) {
|
|
551
|
+
const completed = todoItems.filter((item) => item.completed).length;
|
|
552
|
+
ctx.ui.setStatus(
|
|
553
|
+
STATUS_KEY,
|
|
554
|
+
ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`),
|
|
555
|
+
);
|
|
556
|
+
updateTodoWidget(ctx);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const statusText = planModeEnabled
|
|
561
|
+
? ctx.ui.theme.fg("warning", "⏸ 计划") + ctx.ui.theme.fg("dim", " | plan (Ctrl+Alt+P)")
|
|
562
|
+
: undefined;
|
|
563
|
+
ctx.ui.setStatus(STATUS_KEY, statusText);
|
|
564
|
+
updateTodoWidget(ctx);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const enterPlanMode = (ctx: ExtensionContext): void => {
|
|
568
|
+
if (planModeEnabled) {
|
|
569
|
+
ctx.ui.notify("计划模式已启用。", "info");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const currentTools = pi.getActiveTools();
|
|
574
|
+
restoreTools = currentTools.length > 0 ? [...currentTools] : null;
|
|
575
|
+
|
|
576
|
+
const planTools = getPlanTools();
|
|
577
|
+
if (planTools.length === 0) {
|
|
578
|
+
ctx.ui.notify("无法解析只读工具集。", "error");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
todoItems = [];
|
|
583
|
+
executionMode = false;
|
|
584
|
+
pi.setActiveTools(planTools);
|
|
585
|
+
planModeEnabled = true;
|
|
586
|
+
pi.events.emit("aicode-plan:mode-changed", { enabled: true });
|
|
587
|
+
setStatus(ctx);
|
|
588
|
+
ctx.ui.notify(`计划模式已启用(只读): ${planTools.join(", ")}`, "info");
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const exitPlanMode = (
|
|
592
|
+
ctx: ExtensionContext,
|
|
593
|
+
reason?: string,
|
|
594
|
+
options: { resetProgress?: boolean } = {},
|
|
595
|
+
): void => {
|
|
596
|
+
if (!planModeEnabled) {
|
|
597
|
+
if (reason) {
|
|
598
|
+
ctx.ui.notify(reason);
|
|
599
|
+
}
|
|
600
|
+
if (options.resetProgress) {
|
|
601
|
+
executionMode = false;
|
|
602
|
+
todoItems = [];
|
|
603
|
+
setStatus(ctx);
|
|
604
|
+
}
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
planModeEnabled = false;
|
|
609
|
+
pi.events.emit("aicode-plan:mode-changed", { enabled: false });
|
|
610
|
+
restoreNormalTools();
|
|
611
|
+
if (options.resetProgress) {
|
|
612
|
+
executionMode = false;
|
|
613
|
+
todoItems = [];
|
|
614
|
+
}
|
|
615
|
+
setStatus(ctx);
|
|
616
|
+
if (reason) {
|
|
617
|
+
ctx.ui.notify(reason);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
// 注册命令
|
|
622
|
+
pi.registerCommand("plan", {
|
|
623
|
+
description: "启用只读计划模式。用法:/plan, /plan on, /plan off, /plan status, /plan <任务>",
|
|
624
|
+
handler: async (args, ctx) => {
|
|
625
|
+
const raw = args.trim();
|
|
626
|
+
|
|
627
|
+
if (raw.length === 0) {
|
|
628
|
+
if (planModeEnabled) {
|
|
629
|
+
exitPlanMode(ctx, "计划模式已禁用。返回直接执行模式。", {
|
|
630
|
+
resetProgress: true,
|
|
631
|
+
});
|
|
632
|
+
} else {
|
|
633
|
+
enterPlanMode(ctx);
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const command = normalizeArg(raw);
|
|
639
|
+
if (["on", "enable", "start", "启用", "开启"].includes(command)) {
|
|
640
|
+
enterPlanMode(ctx);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (["off", "disable", "stop", "exit", "禁用", "关闭"].includes(command)) {
|
|
645
|
+
exitPlanMode(ctx, "计划模式已禁用。返回直接执行模式。", {
|
|
646
|
+
resetProgress: true,
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (["status", "state", "状态"].includes(command)) {
|
|
652
|
+
const statusLines = [
|
|
653
|
+
planModeEnabled
|
|
654
|
+
? "计划模式:开启(只读规划)"
|
|
655
|
+
: executionMode
|
|
656
|
+
? "计划模式:关闭(正在执行已批准的计划)"
|
|
657
|
+
: "计划模式:关闭(默认直接执行模式)",
|
|
658
|
+
];
|
|
659
|
+
if (!planModeEnabled && !executionMode) {
|
|
660
|
+
statusLines.push("");
|
|
661
|
+
statusLines.push("快捷键:Alt+Shift+P 快速切换计划模式");
|
|
662
|
+
}
|
|
663
|
+
ctx.ui.notify(statusLines.join("\n"), "info");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (!planModeEnabled) {
|
|
668
|
+
enterPlanMode(ctx);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
pi.sendUserMessage(raw);
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
pi.registerCommand("todos", {
|
|
676
|
+
description: "显示当前计划执行进度",
|
|
677
|
+
handler: async (_args, ctx) => {
|
|
678
|
+
if (todoItems.length === 0) {
|
|
679
|
+
ctx.ui.notify("没有跟踪的计划步骤。先在 /plan 模式中创建计划。", "info");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const completed = todoItems.filter((item) => item.completed).length;
|
|
684
|
+
const progress = `${completed}/${todoItems.length}`;
|
|
685
|
+
const list = todoItems
|
|
686
|
+
.map((item) => `${item.step}. ${item.completed ? "✓" : "○"} ${item.text}`)
|
|
687
|
+
.join("\n");
|
|
688
|
+
ctx.ui.notify(`计划进度 ${progress}\n${list}`, "info");
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// 注册快捷键
|
|
693
|
+
pi.registerShortcut("ctrl+alt+p", {
|
|
694
|
+
description: "切换计划模式",
|
|
695
|
+
handler: async (ctx) => {
|
|
696
|
+
const wasEnabled = planModeEnabled; // 保存当前状态
|
|
697
|
+
if (planModeEnabled) {
|
|
698
|
+
exitPlanMode(ctx, "计划模式已禁用。返回直接执行模式。", {
|
|
699
|
+
resetProgress: true,
|
|
700
|
+
});
|
|
701
|
+
} else {
|
|
702
|
+
enterPlanMode(ctx);
|
|
703
|
+
}
|
|
704
|
+
// 显示切换后的状态
|
|
705
|
+
ctx.ui.notify(wasEnabled ? "计划模式已禁用 ✓" : "计划模式已启用 ✓");
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// 注册消息渲染器
|
|
710
|
+
pi.registerMessageRenderer(CUSTOM_TYPE, (message, { expanded }, theme) => {
|
|
711
|
+
const details = message.details as { kind?: string; message?: string } | undefined;
|
|
712
|
+
const kind = details?.kind ?? "status";
|
|
713
|
+
const msg = details?.message ?? "";
|
|
714
|
+
const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
|
|
715
|
+
box.addChild(new Text(theme.fg("customMessageLabel", theme.bold("📋 计划")), 0, 0));
|
|
716
|
+
box.addChild(new Spacer(1));
|
|
717
|
+
if (!expanded) {
|
|
718
|
+
box.addChild(new Text(`${theme.fg("customMessageText", msg)} ${theme.fg("dim", "(ctrl+o 展开)")}`, 0, 0));
|
|
719
|
+
return box;
|
|
720
|
+
}
|
|
721
|
+
const lines = [
|
|
722
|
+
`${theme.fg("dim", "状态:")}${theme.fg("customMessageText", kind)}`,
|
|
723
|
+
];
|
|
724
|
+
if (msg) {
|
|
725
|
+
lines.push(`${theme.fg("dim", "消息:")}${theme.fg("customMessageText", msg)}`);
|
|
726
|
+
}
|
|
727
|
+
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
728
|
+
return box;
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// 工具调用拦截
|
|
732
|
+
pi.on("tool_call", async (event) => {
|
|
733
|
+
if (!planModeEnabled) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (WRITE_LIKE_TOOLS.has(event.toolName)) {
|
|
738
|
+
return {
|
|
739
|
+
block: true,
|
|
740
|
+
reason: "计划模式是只读的。请先批准执行(选择'批准并立即执行')。",
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (event.toolName === "bash") {
|
|
745
|
+
const input = event.input as { command?: unknown };
|
|
746
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
747
|
+
if (!isSafeReadOnlyCommand(command)) {
|
|
748
|
+
return {
|
|
749
|
+
block: true,
|
|
750
|
+
reason: `计划模式拦截了潜在的变更命令:${command}`,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// agent 启动前注入系统提示词
|
|
757
|
+
pi.on("before_agent_start", async (event) => {
|
|
758
|
+
if (planModeEnabled) {
|
|
759
|
+
return {
|
|
760
|
+
systemPrompt: `${event.systemPrompt}\n\n${PLAN_MODE_SYSTEM_PROMPT}`,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (executionMode && todoItems.length > 0) {
|
|
765
|
+
const remaining = todoItems
|
|
766
|
+
.filter((item) => !item.completed)
|
|
767
|
+
.map((item) => `${item.step}. ${item.text}`)
|
|
768
|
+
.join("\n");
|
|
769
|
+
const executionPrompt = remaining
|
|
770
|
+
? `[已批准计划执行]\n按顺序完成剩余步骤:\n${remaining}\n\n每完成一步后添加 [DONE:n] 标记。`
|
|
771
|
+
: "[已批准计划执行]\n完成实现并验证结果。";
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
systemPrompt: `${event.systemPrompt}\n\n${YOLO_MODE_SYSTEM_PROMPT}\n\n${executionPrompt}`,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
systemPrompt: `${event.systemPrompt}\n\n${YOLO_MODE_SYSTEM_PROMPT}`,
|
|
780
|
+
};
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// turn 结束时标记完成的步骤
|
|
784
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
785
|
+
if (!executionMode || todoItems.length === 0) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const text = getAssistantTextFromMessage(event.message);
|
|
790
|
+
if (!text) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const completedCount = markCompletedSteps(text, todoItems);
|
|
795
|
+
if (completedCount > 0) {
|
|
796
|
+
setStatus(ctx);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (todoItems.every((item) => item.completed)) {
|
|
800
|
+
executionMode = false;
|
|
801
|
+
setStatus(ctx);
|
|
802
|
+
ctx.ui.notify("所有跟踪的计划步骤已完成。", "info");
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
// agent 结束时显示操作菜单
|
|
807
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
808
|
+
if (!planModeEnabled || !ctx.hasUI) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const lastAssistantText = [...event.messages]
|
|
813
|
+
.reverse()
|
|
814
|
+
.map(getAssistantTextFromMessage)
|
|
815
|
+
.find((text) => text.length > 0);
|
|
816
|
+
|
|
817
|
+
if (lastAssistantText) {
|
|
818
|
+
const extracted = extractTodoItems(lastAssistantText);
|
|
819
|
+
if (extracted.length > 0) {
|
|
820
|
+
todoItems = extracted;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
setStatus(ctx);
|
|
824
|
+
|
|
825
|
+
const selection = await selectPlanNextActionWithInlineNote(ctx.ui);
|
|
826
|
+
if (selection.cancelled || !selection.action) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (selection.action === "approve") {
|
|
831
|
+
executionMode = todoItems.length > 0;
|
|
832
|
+
exitPlanMode(ctx, "计划已批准。进入直接执行模式进行实施。");
|
|
833
|
+
|
|
834
|
+
const firstOpenStep = todoItems.find((item) => !item.completed);
|
|
835
|
+
if (firstOpenStep) {
|
|
836
|
+
pi.sendUserMessage(
|
|
837
|
+
`${EXECUTION_TRIGGER_PROMPT} 从步骤 ${firstOpenStep.step} 开始:${firstOpenStep.text}`,
|
|
838
|
+
);
|
|
839
|
+
} else {
|
|
840
|
+
pi.sendUserMessage(EXECUTION_TRIGGER_PROMPT);
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (selection.action === "regenerate") {
|
|
846
|
+
todoItems = [];
|
|
847
|
+
setStatus(ctx);
|
|
848
|
+
pi.sendUserMessage(
|
|
849
|
+
"从头重新生成计划。重新检查上下文并提供刷新后的 Plan: 部分。",
|
|
850
|
+
);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (selection.action === "continue") {
|
|
855
|
+
const continueNote = selection.continueNote?.trim() ?? "";
|
|
856
|
+
if (continueNote.length === 0) {
|
|
857
|
+
ctx.ui.notify(
|
|
858
|
+
"请输入请求的修改内容,然后发送消息继续规划。等待你的输入。",
|
|
859
|
+
"info",
|
|
860
|
+
);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const firstOpenStep = todoItems.find((item) => !item.completed);
|
|
865
|
+
if (firstOpenStep) {
|
|
866
|
+
pi.sendUserMessage(
|
|
867
|
+
`从提议的计划继续。用户备注:${continueNote}。聚焦步骤 ${firstOpenStep.step}: ${firstOpenStep.text}。优化文件、验证和风险分析。`,
|
|
868
|
+
);
|
|
869
|
+
} else {
|
|
870
|
+
pi.sendUserMessage(
|
|
871
|
+
`从提议的计划继续。用户备注:${continueNote}。无需重新生成完整计划,优化实现细节。`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (selection.action === "exit") {
|
|
878
|
+
exitPlanMode(ctx, "未执行就退出了计划模式。", {
|
|
879
|
+
resetProgress: true,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// session 启动时恢复状态
|
|
885
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
886
|
+
setStatus(ctx);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// session 关闭时清理
|
|
890
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
891
|
+
executionMode = false;
|
|
892
|
+
if (ctx.hasUI) {
|
|
893
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
894
|
+
ctx.ui.setWidget(TODO_WIDGET_KEY, undefined);
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
}
|