@ia-ccun/code-agent-cli 0.0.52-release → 0.0.53-beta.1
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 -2
- package/config/agent/bin/README.md +102 -0
- package/config/agent/extensions/aicode-advisor.ts +13 -0
- package/config/agent/extensions/aicode-btw.ts +66 -14
- package/config/agent/extensions/aicode-cd.ts +834 -0
- package/config/agent/extensions/aicode-grillMe.ts +198 -0
- package/config/agent/extensions/aicode-pets.ts +362 -0
- package/config/agent/extensions/aicode-safety-guard.ts +552 -0
- package/config/agent/extensions/aicode-tips.ts +70 -16
- package/config/agent/extensions/aicode-todo-progress.ts +547 -0
- package/config/agent/extensions/custom.ts +93 -95
- package/config/agent/extensions/error-monitor.ts +1 -18
- package/config/agent/extensions/ponytail.ts +441 -0
- package/config/agent/extensions/tps.ts +87 -0
- package/config/agent/extensions/working-msg.ts +165 -50
- package/config/agent/help.md +0 -7
- package/config/agent/models.json +7 -7
- package/config/agent/settings.json +2 -1
- package/config/agent/skills/chroma/LICENSE +21 -0
- package/config/agent/skills/chroma/chroma-local/SKILL.md +165 -0
- package/config/agent/skills/chroma/chroma-local/cli.md +48 -0
- package/config/agent/skills/chroma/chroma-local/data-model.md +103 -0
- package/config/agent/skills/chroma/chroma-local/error-handling/python.md +166 -0
- package/config/agent/skills/chroma/chroma-local/error-handling/typescript.md +231 -0
- package/config/agent/skills/chroma/chroma-local/local-chroma/python.md +95 -0
- package/config/agent/skills/chroma/chroma-local/local-chroma/typescript.md +101 -0
- package/config/agent/skills/chroma/chroma-local/metadata/python.md +154 -0
- package/config/agent/skills/chroma/chroma-local/metadata/typescript.md +153 -0
- package/config/agent/skills/chroma/chroma-local/querying/python.md +147 -0
- package/config/agent/skills/chroma/chroma-local/querying/typescript.md +157 -0
- package/config/agent/skills/chroma/chroma-local/regex/python.md +69 -0
- package/config/agent/skills/chroma/chroma-local/regex/typescript.md +71 -0
- package/config/agent/skills/chroma/chroma-local/understanding-a-codebase.md +80 -0
- package/config/agent/skills/chroma/chroma-local/updating-deleting/python.md +159 -0
- package/config/agent/skills/chroma/chroma-local/updating-deleting/typescript.md +228 -0
- package/config/agent/skills/milvus-skill/LICENSE +202 -0
- package/config/agent/skills/milvus-skill/README.md +37 -0
- package/config/agent/skills/milvus-skill/SKILL.md +259 -0
- package/config/agent/skills/milvus-skill/references/collection.md +86 -0
- package/config/agent/skills/milvus-skill/references/database.md +24 -0
- package/config/agent/skills/milvus-skill/references/index.md +69 -0
- package/config/agent/skills/milvus-skill/references/partition.md +29 -0
- package/config/agent/skills/milvus-skill/references/patterns.md +150 -0
- package/config/agent/skills/milvus-skill/references/user-role.md +90 -0
- package/config/agent/skills/milvus-skill/references/vector.md +247 -0
- package/config/agent/skills/ponytail/SKILL.md +100 -0
- package/config/agent/skills/ponytail-audit/SKILL.md +40 -0
- package/config/agent/skills/ponytail-debt/SKILL.md +44 -0
- package/config/agent/skills/ponytail-help/SKILL.md +68 -0
- package/config/agent/skills/ponytail-review/SKILL.md +56 -0
- package/config/agent/skills/zread/README.md +115 -0
- package/config/agent/skills/zread/SKILL.md +103 -0
- package/config/agent/skills/zread/references/stdio-protocol.md +312 -0
- package/package.json +6 -5
- package/scripts/postinstall.js +58 -1
- package/scripts/regression/README.md +224 -0
- package/scripts/regression/cases/01-version-and-deps.sh +94 -0
- package/scripts/regression/cases/02-extension-loading.sh +130 -0
- package/scripts/regression/cases/03-rpc-protocol.sh +259 -0
- package/scripts/regression/cases/04-prompt-stream.sh +130 -0
- package/scripts/regression/cases/05-slash-commands-and-skills.sh +119 -0
- package/scripts/regression/cases/06-tool-execution.sh +115 -0
- package/scripts/regression/cases/07-pi-new-features.sh +141 -0
- package/scripts/regression/lib/common.sh +287 -0
- package/scripts/regression/results/.last_pid +1 -0
- package/scripts/regression/results/.last_workdir +1 -0
- package/scripts/regression/results/02-extension-loading.log +29 -0
- package/scripts/regression/results/03-prompt-stream.log +25 -0
- package/scripts/regression/results/03-rpc-protocol.log +38 -0
- package/scripts/regression/results/04-slash-commands-and-skills.log +10 -0
- package/scripts/regression/results/05-tool-execution.log +6 -0
- package/scripts/regression/results/06-pi-new-features.log +33 -0
- package/scripts/regression/run-all.sh +187 -0
- package/config/agent/dangerous-patterns.json +0 -106
- package/config/agent/extensions/bash-check.ts +0 -227
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @author: xujianjiang
|
|
3
|
+
* Todo 进度小组件扩展
|
|
4
|
+
*
|
|
5
|
+
* 功能说明:
|
|
6
|
+
* - 自动从 AI 回复中提取 markdown checklist(- [ ] / [-] / [x])
|
|
7
|
+
* - 在 TUI 中渲染为滚动进度小组件,显示目标和完成比例
|
|
8
|
+
* - 将待办上下文注入到每轮对话前,指导 AI 更新进度
|
|
9
|
+
* - 支持快捷键:Ctrl+Alt+X 关闭、Ctrl+Alt+J/K 上下滚动
|
|
10
|
+
* - 状态持久化到会话文件中,跨会话恢复
|
|
11
|
+
*
|
|
12
|
+
* 快捷键:
|
|
13
|
+
* Ctrl+Alt+X 关闭进度小组件
|
|
14
|
+
* Ctrl+Alt+J 向下滚动
|
|
15
|
+
* Ctrl+Alt+K 向上滚动
|
|
16
|
+
*
|
|
17
|
+
* 命令:
|
|
18
|
+
* /todo-progress-status 查看扩展状态
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
// ============== Checklist 工具函数(内联自 @firstpick/pi-utils) ==============
|
|
24
|
+
|
|
25
|
+
type ChecklistStatus = "todo" | "partial" | "done";
|
|
26
|
+
type ChecklistItem = {
|
|
27
|
+
text: string;
|
|
28
|
+
status: ChecklistStatus;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CHECKLIST_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[( |x|X|-)\]\s+(.+)$/;
|
|
32
|
+
|
|
33
|
+
function parseChecklistLine(line: string): ChecklistItem | undefined {
|
|
34
|
+
const match = CHECKLIST_LINE_REGEX.exec(line);
|
|
35
|
+
if (!match) return undefined;
|
|
36
|
+
|
|
37
|
+
const mark = (match[1] || " ").toLowerCase();
|
|
38
|
+
const label = (match[2] || "").trim().replace(/\s+/g, " ");
|
|
39
|
+
if (!label) return undefined;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
status: mark === "x" ? "done" : mark === "-" ? "partial" : "todo",
|
|
43
|
+
text: label,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractChecklist(text: string): ChecklistItem[] {
|
|
48
|
+
const checklist: ChecklistItem[] = [];
|
|
49
|
+
let inFence = false;
|
|
50
|
+
|
|
51
|
+
for (const line of text.split(/\r?\n/)) {
|
|
52
|
+
if (/^\s*```/.test(line)) {
|
|
53
|
+
inFence = !inFence;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (inFence) continue;
|
|
57
|
+
|
|
58
|
+
const item = parseChecklistLine(line);
|
|
59
|
+
if (item) checklist.push(item);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return checklist;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stripChecklistLines(text: string): string {
|
|
66
|
+
let inFence = false;
|
|
67
|
+
const kept: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (const line of text.split(/\r?\n/)) {
|
|
70
|
+
if (/^\s*```/.test(line)) {
|
|
71
|
+
inFence = !inFence;
|
|
72
|
+
kept.push(line);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!inFence && parseChecklistLine(line)) continue;
|
|
77
|
+
kept.push(line);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type TodoStatus = ChecklistStatus;
|
|
84
|
+
type TodoItem = ChecklistItem;
|
|
85
|
+
type TodoState = {
|
|
86
|
+
visible: boolean;
|
|
87
|
+
items: TodoItem[];
|
|
88
|
+
offset: number;
|
|
89
|
+
goal?: string;
|
|
90
|
+
awaitingGoalCheck: boolean;
|
|
91
|
+
};
|
|
92
|
+
type PersistedTodoState = TodoState & { version: 1 };
|
|
93
|
+
|
|
94
|
+
const KEY = "aicode-todo-progress";
|
|
95
|
+
const STATE_KEY = "aicode-todo-progress-state";
|
|
96
|
+
const CONTEXT_KEY = "aicode-todo-progress-context";
|
|
97
|
+
const MAX_ROWS = 5;
|
|
98
|
+
const MAX_ITEMS = 12;
|
|
99
|
+
|
|
100
|
+
const TODO_POLICY = [
|
|
101
|
+
"",
|
|
102
|
+
"",
|
|
103
|
+
"[TODO PROGRESS POLICY] You are AICode, a tool-assistant for a human user. For multi-step work:",
|
|
104
|
+
"- First formulate a concise one-sentence `Goal: ...` before creating a todo list or starting execution.",
|
|
105
|
+
"- Create concise, agent-authored checklists with 2-6 short items. Keep the goal separate; the todo list does not need to contain the goal.",
|
|
106
|
+
"- Emit markdown checklist lines exactly like `- [ ] item`, `- [-] item`, or `- [x] item` only when starting a list or when item status/text changes; do not re-emit unchanged checklist items before every tool call.",
|
|
107
|
+
"- Do not copy raw user-prompt lines as todos; rewrite them into clear action items.",
|
|
108
|
+
"- Update checklist markers as work changes. Mark the active/current step `[-]` when useful and completed steps `[x]`; emitting only the changed checklist line(s) is enough.",
|
|
109
|
+
"- When every item in the current list is `[x]`, explicitly check whether the goal is reached before doing more work.",
|
|
110
|
+
"- If the goal is reached, stop creating todo lists and produce the final output. If the goal is not reached, create a new short checklist before the next execution step.",
|
|
111
|
+
"- Multiple todo lists may be created during one session; each new list replaces the previous list in the progress widget.",
|
|
112
|
+
"- Todo checklists are session progress: still emit `[x]` updates when possible; after a normal final assistant response the extension clears the widget automatically so stale partial lists do not persist.",
|
|
113
|
+
].join("\n");
|
|
114
|
+
|
|
115
|
+
function statusLabel(status: TodoStatus): string {
|
|
116
|
+
if (status === "done") return "[x]";
|
|
117
|
+
if (status === "partial") return "[-]";
|
|
118
|
+
return "[ ]";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isDoneList(items: TodoItem[]): boolean {
|
|
122
|
+
return items.length > 0 && items.every((item) => item.status === "done");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lastAssistantMessage(messages: any[]): any | undefined {
|
|
126
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
127
|
+
const message = messages[index];
|
|
128
|
+
if (message?.role === "assistant") return message;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assistantText(message: any): string {
|
|
134
|
+
if (!Array.isArray(message?.content)) return "";
|
|
135
|
+
return message.content
|
|
136
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
137
|
+
.map((part: any) => part.text)
|
|
138
|
+
.join("\n")
|
|
139
|
+
.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function assistantHasToolCalls(message: any): boolean {
|
|
143
|
+
return Array.isArray(message?.content) && message.content.some((part: any) => part?.type === "toolCall");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function shouldAutoClearOnAgentEnd(messages: any[], s: TodoState): boolean {
|
|
147
|
+
if (s.items.length === 0) return false;
|
|
148
|
+
|
|
149
|
+
const finalAssistant = lastAssistantMessage(messages);
|
|
150
|
+
if (!finalAssistant) return false;
|
|
151
|
+
|
|
152
|
+
// Do not erase active progress for interrupted, failed, truncated, or tool-use
|
|
153
|
+
// terminal states. A normal final assistant text means the run has handed the
|
|
154
|
+
// result back to the user, so stale partial markers should not remain visible.
|
|
155
|
+
if (["aborted", "error", "length", "toolUse"].includes(finalAssistant.stopReason)) return false;
|
|
156
|
+
if (assistantHasToolCalls(finalAssistant)) return false;
|
|
157
|
+
|
|
158
|
+
return assistantText(finalAssistant).length > 0 || isDoneList(s.items);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractChecklistBlocks(text: string): TodoItem[][] {
|
|
162
|
+
const blocks: TodoItem[][] = [];
|
|
163
|
+
let current: TodoItem[] = [];
|
|
164
|
+
let inFence = false;
|
|
165
|
+
|
|
166
|
+
for (const line of text.split(/\r?\n/)) {
|
|
167
|
+
if (/^\s*```/.test(line)) {
|
|
168
|
+
inFence = !inFence;
|
|
169
|
+
if (current.length > 0) {
|
|
170
|
+
blocks.push(current);
|
|
171
|
+
current = [];
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const item = inFence ? undefined : extractChecklist(line)[0];
|
|
177
|
+
if (item) {
|
|
178
|
+
current.push(item);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (current.length > 0) {
|
|
183
|
+
blocks.push(current);
|
|
184
|
+
current = [];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (current.length > 0) blocks.push(current);
|
|
189
|
+
return blocks;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeTodoText(text: string): string {
|
|
193
|
+
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function todoItemKey(item: TodoItem): string {
|
|
197
|
+
return normalizeTodoText(item.text);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sameTodoItems(a: TodoItem[], b: TodoItem[]): boolean {
|
|
201
|
+
return a.length === b.length && a.every((item, index) => item.status === b[index]?.status && item.text === b[index]?.text);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function bestChecklistBlock(blocks: TodoItem[][], previousItems: TodoItem[]): TodoItem[] {
|
|
205
|
+
if (blocks.length === 0) return [];
|
|
206
|
+
const previousKeys = new Set(previousItems.map(todoItemKey));
|
|
207
|
+
let best = blocks[0].slice(0, MAX_ITEMS);
|
|
208
|
+
let bestScore = [-1, best.length, 0];
|
|
209
|
+
|
|
210
|
+
blocks.forEach((block, index) => {
|
|
211
|
+
const items = block.slice(0, MAX_ITEMS);
|
|
212
|
+
const overlap = items.filter((item) => previousKeys.has(todoItemKey(item))).length;
|
|
213
|
+
const score = previousKeys.size > 0 ? [overlap, items.length, index] : [items.length, items.length, index];
|
|
214
|
+
if (score[0] > bestScore[0] || (score[0] === bestScore[0] && (score[1] > bestScore[1] || (score[1] === bestScore[1] && score[2] > bestScore[2])))) {
|
|
215
|
+
best = items;
|
|
216
|
+
bestScore = score;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return best;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function extractBestChecklist(texts: string[], previousItems: TodoItem[]): TodoItem[] {
|
|
224
|
+
return bestChecklistBlock(texts.flatMap((text) => extractChecklistBlocks(text)), previousItems);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function shouldAcceptInitialList(incomingItems: TodoItem[]): boolean {
|
|
228
|
+
// A single non-todo line such as `- [-] Verify build` is usually a status
|
|
229
|
+
// delta emitted without the full list. Do not let it become a new one-item
|
|
230
|
+
// canonical list that later expands unpredictably.
|
|
231
|
+
return incomingItems.length > 1 || incomingItems[0]?.status === "todo";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function shouldReplaceList(previousItems: TodoItem[], incomingItems: TodoItem[], overlap: number): boolean {
|
|
235
|
+
if (incomingItems.length === 0) return false;
|
|
236
|
+
|
|
237
|
+
// New lists are allowed once the prior list is complete and the model has
|
|
238
|
+
// checked the goal. While work is active, unrelated or low-overlap blocks are
|
|
239
|
+
// treated as stray status output instead of replacing the canonical list.
|
|
240
|
+
if (isDoneList(previousItems)) return shouldAcceptInitialList(incomingItems);
|
|
241
|
+
if (overlap === 0 || incomingItems.length < previousItems.length) return false;
|
|
242
|
+
|
|
243
|
+
const previousOverlapRatio = overlap / previousItems.length;
|
|
244
|
+
const incomingOverlapRatio = overlap / incomingItems.length;
|
|
245
|
+
return previousOverlapRatio >= 0.75 && incomingOverlapRatio >= 0.5;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function mergeChecklistItems(previousItems: TodoItem[], incomingItems: TodoItem[]): TodoItem[] {
|
|
249
|
+
if (incomingItems.length === 0) return previousItems.slice(0, MAX_ITEMS);
|
|
250
|
+
if (previousItems.length === 0) return shouldAcceptInitialList(incomingItems) ? incomingItems.slice(0, MAX_ITEMS) : [];
|
|
251
|
+
|
|
252
|
+
const previousByKey = new Map(previousItems.map((item) => [todoItemKey(item), item]));
|
|
253
|
+
const incomingByKey = new Map(incomingItems.map((item) => [todoItemKey(item), item]));
|
|
254
|
+
const overlap = incomingItems.filter((item) => previousByKey.has(todoItemKey(item))).length;
|
|
255
|
+
|
|
256
|
+
if (shouldReplaceList(previousItems, incomingItems, overlap)) return incomingItems.slice(0, MAX_ITEMS);
|
|
257
|
+
|
|
258
|
+
// Otherwise only apply status/text changes for existing items. Do not append
|
|
259
|
+
// new unrelated items during active progress; that was the source of one-item
|
|
260
|
+
// status deltas expanding into a different list mid-run.
|
|
261
|
+
return previousItems.map((item) => incomingByKey.get(todoItemKey(item)) ?? item).slice(0, MAX_ITEMS);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function cleanGoalText(value: string | undefined): string | undefined {
|
|
265
|
+
const goal = value
|
|
266
|
+
?.replace(/^\s*(?:`+|\*+|_+)+/, "")
|
|
267
|
+
.replace(/(?:`+|\*+|_+)+\s*$/, "")
|
|
268
|
+
.trim()
|
|
269
|
+
.replace(/\s+/g, " ");
|
|
270
|
+
return goal || undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function extractGoal(text: string): string | undefined {
|
|
274
|
+
let inFence = false;
|
|
275
|
+
|
|
276
|
+
for (const line of text.split(/\r?\n/)) {
|
|
277
|
+
if (/^\s*```/.test(line)) {
|
|
278
|
+
inFence = !inFence;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (inFence) continue;
|
|
282
|
+
|
|
283
|
+
const match = /^\s*(?:[-*+]\s*)?(?:>\s*)?(?:\*\*|__)?\s*Goal\s*(?:\*\*|__)?\s*[::—–-]\s*(.+)$/i.exec(line);
|
|
284
|
+
const goal = cleanGoalText(match?.[1]);
|
|
285
|
+
if (goal) return goal;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function fallbackGoalFromPrompt(prompt: string): string | undefined {
|
|
292
|
+
const explicit = extractGoal(prompt);
|
|
293
|
+
if (explicit) return explicit;
|
|
294
|
+
|
|
295
|
+
const firstMeaningfulLine = prompt
|
|
296
|
+
.split(/\r?\n/)
|
|
297
|
+
.map((line) => line.trim())
|
|
298
|
+
.find((line) => line && !line.startsWith("/") && !line.startsWith("[TODO PROGRESS CONTEXT]"));
|
|
299
|
+
|
|
300
|
+
return cleanGoalText(firstMeaningfulLine)?.slice(0, 160);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function clear(ctx: ExtensionContext, s: TodoState, options: { keepGoal?: boolean } = {}) {
|
|
304
|
+
const hadWidget = s.visible || s.items.length > 0;
|
|
305
|
+
const goal = s.goal;
|
|
306
|
+
s.visible = false;
|
|
307
|
+
s.items = [];
|
|
308
|
+
s.offset = 0;
|
|
309
|
+
s.goal = options.keepGoal ? goal : undefined;
|
|
310
|
+
s.awaitingGoalCheck = false;
|
|
311
|
+
if (ctx.hasUI && hadWidget) ctx.ui.setWidget(KEY, undefined);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hideWidget(ctx: ExtensionContext) {
|
|
315
|
+
if (ctx.hasUI) ctx.ui.setWidget(KEY, undefined);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function snapshotState(s: TodoState): PersistedTodoState {
|
|
319
|
+
return {
|
|
320
|
+
version: 1,
|
|
321
|
+
visible: s.visible,
|
|
322
|
+
items: s.items.map((item) => ({ ...item })),
|
|
323
|
+
offset: s.offset,
|
|
324
|
+
goal: s.goal,
|
|
325
|
+
awaitingGoalCheck: s.awaitingGoalCheck,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isTodoStatus(value: unknown): value is TodoStatus {
|
|
330
|
+
return value === "todo" || value === "partial" || value === "done";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function restoreSnapshot(data: unknown): TodoState | undefined {
|
|
334
|
+
if (!data || typeof data !== "object") return undefined;
|
|
335
|
+
const snapshot = data as Partial<PersistedTodoState>;
|
|
336
|
+
if (snapshot.version !== 1 || !Array.isArray(snapshot.items)) return undefined;
|
|
337
|
+
|
|
338
|
+
const items = snapshot.items.flatMap((item: any) => {
|
|
339
|
+
if (!item || typeof item.text !== "string" || !isTodoStatus(item.status)) return [];
|
|
340
|
+
return [{ text: item.text, status: item.status }];
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
visible: Boolean(snapshot.visible) && items.length > 0,
|
|
345
|
+
items: items.slice(0, MAX_ITEMS),
|
|
346
|
+
offset: Math.max(0, Math.min(Number(snapshot.offset) || 0, Math.max(0, items.length - MAX_ROWS))),
|
|
347
|
+
goal: typeof snapshot.goal === "string" && snapshot.goal.trim() ? snapshot.goal : undefined,
|
|
348
|
+
awaitingGoalCheck: Boolean(snapshot.awaitingGoalCheck),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function render(ctx: ExtensionContext, s: TodoState) {
|
|
353
|
+
if (!ctx.hasUI) return;
|
|
354
|
+
if (!s.visible || s.items.length === 0) {
|
|
355
|
+
ctx.ui.setWidget(KEY, undefined);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const done = s.items.filter((i) => i.status === "done").length;
|
|
360
|
+
const partial = s.items.filter((i) => i.status === "partial").length;
|
|
361
|
+
const allDone = done === s.items.length;
|
|
362
|
+
const top = s.items.slice(s.offset, s.offset + MAX_ROWS);
|
|
363
|
+
const title = allDone
|
|
364
|
+
? `Todo ${done}/${s.items.length} done · check goal`
|
|
365
|
+
: `Todo ${done}/${s.items.length} done${partial ? `, ${partial} partial` : ""}`;
|
|
366
|
+
const lines = [ctx.ui.theme.fg("accent", `Goal: ${s.goal ?? "not formulated yet"}`)];
|
|
367
|
+
lines.push(ctx.ui.theme.fg(allDone ? "success" : "accent", title));
|
|
368
|
+
for (const item of top) lines.push(`${statusLabel(item.status)} ${item.text}`);
|
|
369
|
+
if (s.items.length > MAX_ROWS) lines.push(ctx.ui.theme.fg("dim", `Scroll ${s.offset + 1}-${Math.min(s.offset + MAX_ROWS, s.items.length)} of ${s.items.length}`));
|
|
370
|
+
ctx.ui.setWidget(KEY, lines);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function buildInjectedContext(s: TodoState): string {
|
|
374
|
+
const lines = ["[TODO PROGRESS CONTEXT]"];
|
|
375
|
+
lines.push(s.goal ? `Goal: ${s.goal}` : "Goal: not formulated yet. Formulate `Goal: ...` before creating the first checklist or starting work.");
|
|
376
|
+
|
|
377
|
+
if (s.items.length > 0) {
|
|
378
|
+
lines.push("", "Current todo list injected before the next step:");
|
|
379
|
+
for (const item of s.items) lines.push(`- ${statusLabel(item.status)} ${item.text}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (s.awaitingGoalCheck || isDoneList(s.items)) {
|
|
383
|
+
lines.push(
|
|
384
|
+
"",
|
|
385
|
+
"The current todo list is complete. Before any additional tool call or execution step, check whether the goal is reached.",
|
|
386
|
+
"If the goal is reached, produce the final output and stop creating todo lists. If not, create a new 2-6 item checklist first.",
|
|
387
|
+
);
|
|
388
|
+
} else if (s.items.length > 0) {
|
|
389
|
+
lines.push(
|
|
390
|
+
"",
|
|
391
|
+
"Before the next execution step or tool call, emit checklist lines only for items whose status/text changed. If the checklist did not change, do not re-emit unchanged checklist lines.",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return lines.join("\n");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export default function todoProgress(pi: ExtensionAPI) {
|
|
399
|
+
const state: TodoState = { visible: false, items: [], offset: 0, awaitingGoalCheck: false };
|
|
400
|
+
|
|
401
|
+
function persistState() {
|
|
402
|
+
pi.appendEntry(STATE_KEY, snapshotState(state));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function restoreState(ctx: ExtensionContext) {
|
|
406
|
+
const entries = ctx.sessionManager.getBranch();
|
|
407
|
+
const saved = entries
|
|
408
|
+
.filter((entry: any) => entry.type === "custom" && entry.customType === STATE_KEY)
|
|
409
|
+
.map((entry: any) => restoreSnapshot(entry.data))
|
|
410
|
+
.filter((snapshot): snapshot is TodoState => Boolean(snapshot))
|
|
411
|
+
.at(-1);
|
|
412
|
+
|
|
413
|
+
if (!saved) {
|
|
414
|
+
clear(ctx, state);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
state.visible = saved.visible;
|
|
419
|
+
state.items = saved.items;
|
|
420
|
+
state.offset = saved.offset;
|
|
421
|
+
state.goal = saved.goal;
|
|
422
|
+
state.awaitingGoalCheck = saved.awaitingGoalCheck;
|
|
423
|
+
render(ctx, state);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
427
|
+
clear(ctx, state);
|
|
428
|
+
state.goal = fallbackGoalFromPrompt(event.prompt);
|
|
429
|
+
persistState();
|
|
430
|
+
return { systemPrompt: event.systemPrompt + TODO_POLICY };
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
pi.on("context", async (event) => {
|
|
434
|
+
const messages = event.messages.filter((message: any) => message?.customType !== CONTEXT_KEY);
|
|
435
|
+
if (!state.goal && state.items.length === 0) {
|
|
436
|
+
return messages.length === event.messages.length ? undefined : { messages };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
messages: [
|
|
441
|
+
...messages,
|
|
442
|
+
{
|
|
443
|
+
role: "custom",
|
|
444
|
+
customType: CONTEXT_KEY,
|
|
445
|
+
content: buildInjectedContext(state),
|
|
446
|
+
display: false,
|
|
447
|
+
timestamp: Date.now(),
|
|
448
|
+
} as any,
|
|
449
|
+
],
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
pi.on("input", async (event, ctx) => {
|
|
454
|
+
if (!event.text.startsWith("/")) {
|
|
455
|
+
clear(ctx, state);
|
|
456
|
+
persistState();
|
|
457
|
+
}
|
|
458
|
+
return { action: "continue" as const };
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
pi.on("message_end", async (event, ctx) => {
|
|
462
|
+
if (event.message.role !== "assistant") return;
|
|
463
|
+
|
|
464
|
+
const textParts = event.message.content.filter((c: any) => c.type === "text");
|
|
465
|
+
const texts = textParts.map((c: any) => c.text);
|
|
466
|
+
const previousGoal = state.goal;
|
|
467
|
+
const previousItems = state.items.map((item) => ({ ...item }));
|
|
468
|
+
const goal = texts.map(extractGoal).find(Boolean);
|
|
469
|
+
if (goal) state.goal = goal;
|
|
470
|
+
|
|
471
|
+
const checklist = extractBestChecklist(texts, previousItems);
|
|
472
|
+
if (checklist.length === 0) {
|
|
473
|
+
if (state.goal !== previousGoal && state.visible && state.items.length > 0) {
|
|
474
|
+
render(ctx, state);
|
|
475
|
+
persistState();
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const nextItems = mergeChecklistItems(previousItems, checklist);
|
|
481
|
+
const nextAwaitingGoalCheck = isDoneList(nextItems);
|
|
482
|
+
const nextOffset = Math.min(state.offset, Math.max(0, nextItems.length - MAX_ROWS));
|
|
483
|
+
const changed = state.goal !== previousGoal || !sameTodoItems(state.items, nextItems) || state.awaitingGoalCheck !== nextAwaitingGoalCheck || state.offset !== nextOffset || !state.visible;
|
|
484
|
+
|
|
485
|
+
if (changed) {
|
|
486
|
+
state.items = nextItems;
|
|
487
|
+
state.awaitingGoalCheck = nextAwaitingGoalCheck;
|
|
488
|
+
state.offset = nextOffset;
|
|
489
|
+
state.visible = true;
|
|
490
|
+
render(ctx, state);
|
|
491
|
+
persistState();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
message: {
|
|
496
|
+
...event.message,
|
|
497
|
+
content: event.message.content.map((c: any) => (c.type === "text" ? { ...c, text: stripChecklistLines(c.text) } : c)),
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
503
|
+
if (shouldAutoClearOnAgentEnd(event.messages, state)) {
|
|
504
|
+
clear(ctx, state);
|
|
505
|
+
persistState();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Keep active progress visible for interrupted/failed/tool-use terminal states.
|
|
510
|
+
render(ctx, state);
|
|
511
|
+
persistState();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
pi.on("session_shutdown", async () => persistState());
|
|
515
|
+
pi.on("session_before_switch", async (_event, ctx) => {
|
|
516
|
+
persistState();
|
|
517
|
+
hideWidget(ctx);
|
|
518
|
+
return undefined;
|
|
519
|
+
});
|
|
520
|
+
pi.on("session_before_fork", async (_event, ctx) => {
|
|
521
|
+
persistState();
|
|
522
|
+
hideWidget(ctx);
|
|
523
|
+
return undefined;
|
|
524
|
+
});
|
|
525
|
+
pi.on("session_tree", async (_event, ctx) => restoreState(ctx));
|
|
526
|
+
|
|
527
|
+
pi.registerShortcut("ctrl+alt+x", {
|
|
528
|
+
description: "Dismiss completed todo widget",
|
|
529
|
+
handler: async (ctx) => {
|
|
530
|
+
clear(ctx, state);
|
|
531
|
+
persistState();
|
|
532
|
+
ctx.ui.notify("Todo widget dismissed", "info");
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
pi.registerShortcut("ctrl+alt+j", { description: "Todo scroll down", handler: async (ctx) => { state.offset = Math.min(Math.max(0, state.items.length - MAX_ROWS), state.offset + 1); render(ctx, state); persistState(); } });
|
|
537
|
+
pi.registerShortcut("ctrl+alt+k", { description: "Todo scroll up", handler: async (ctx) => { state.offset = Math.max(0, state.offset - 1); render(ctx, state); persistState(); } });
|
|
538
|
+
|
|
539
|
+
pi.registerCommand("todo-progress-status", {
|
|
540
|
+
description: "Show todo-progress widget extension status",
|
|
541
|
+
handler: async (_args, ctx) => {
|
|
542
|
+
ctx.ui.notify(`todo-progress loaded · visible ${state.visible ? "yes" : "no"} · items ${state.items.length} · goal ${state.goal ? "yes" : "no"}`, "info");
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
pi.on("session_start", async (_event, ctx) => restoreState(ctx));
|
|
547
|
+
}
|