/** * Agent Todos — Cursor TodoWrite 风格的任务清单 * * 复杂任务先拆分;完整列表显示在 editor 上方,可用 /todos 手动隐藏或显示。 */ import { Type, type Static } from "@earendil-works/pi-ai"; import type { ContextEvent, ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { countTodos, formatTodosForModel, summarizeChanges, syncTodosWithSubagents, validateTodoWrite, type TodoWriteDetails, } from "./model.js"; import { TODO_WRITE_PROMPT_GUIDELINES, TODO_WRITE_PROMPT_SNIPPET, TOOL_NAME, todoFocusReminder, todoSystemPromptAppend, } from "./prompt.js"; import { renderTodoCall, renderTodoResult } from "./render.js"; import { registerAboveEditorRestack } from "../shared/tui/widget-restack.js"; import { subscribeCompletedSubagents } from "./subagent-sync.js"; import { TODO_SYNC_ENTRY_TYPE, TodoStore } from "./store.js"; import { clearTodoUI, hideTodoPanel, refreshTodoUI, restackTodoUI, } from "./ui.js"; const TODO_STATUSES = [ "pending", "in_progress", "completed", "cancelled", ] as const; const TodoStatusSchema = Type.Unsafe<(typeof TODO_STATUSES)[number]>({ type: "string", enum: TODO_STATUSES, description: "Todo status", }); const TodoWriteParams = Type.Object({ merge: Type.Boolean({ description: "true: merge by id into the existing list; false: replace the whole list (empty array clears)", }), todos: Type.Array( Type.Object({ id: Type.String({ description: "Stable short id (prefer kebab-case)", }), content: Type.String({ description: "Task description" }), status: TodoStatusSchema, subagentId: Type.Optional( Type.String({ description: "Link this item to a spawned subagent; it is marked completed automatically when that child finishes successfully", }), ), }), { description: "Todo items. With merge=false use 0 (clear) or ≥2 items; with merge=true pass only changes.", }, ), }); export default function agentTodosExtension(pi: ExtensionAPI) { const store = new TodoStore(); let panelVisible = true; const updateUI = (ctx: Parameters[0]) => { if (panelVisible) refreshTodoUI(ctx, store); else hideTodoPanel(ctx, store); }; const reconstruct = (ctx: Parameters[0]) => { store.reconstructFromBranch(ctx.sessionManager.getBranch()); panelVisible = true; updateUI(ctx); }; const unregisterRestack = registerAboveEditorRestack((ctx) => { if (panelVisible) restackTodoUI(ctx as ExtensionContext, store); }); let sessionCtx: ExtensionContext | undefined; const unsubscribeSubagents = subscribeCompletedSubagents((completedIds) => { if (!sessionCtx) return; const { todos, completed } = syncTodosWithSubagents(store.getTodos(), completedIds); if (completed.length === 0) return; store.setTodos(todos); updateUI(sessionCtx); pi.appendEntry(TODO_SYNC_ENTRY_TYPE, { todos, completed: completed.map((todo) => todo.id) }); }); pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => { sessionCtx = ctx; reconstruct(ctx); }); pi.on("session_tree", async (_event: unknown, ctx: ExtensionContext) => reconstruct(ctx), ); pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => { sessionCtx = undefined; unregisterRestack(); unsubscribeSubagents(); clearTodoUI(ctx); }); pi.registerCommand("todos", { description: "Show or hide the todo panel above the editor", handler: (_args: string, ctx: ExtensionCommandContext): Promise => { if (store.getTodos().length === 0) { ctx.ui.notify("No todos to show.", "info"); return Promise.resolve(); } panelVisible = !panelVisible; updateUI(ctx); ctx.ui.notify(`Todo panel ${panelVisible ? "shown" : "hidden"}.`, "info"); return Promise.resolve(); }, }); pi.on("before_agent_start", async (event: { systemPrompt: string }) => ({ systemPrompt: `${event.systemPrompt}\n\n${todoSystemPromptAppend()}`, })); pi.on("context", async (event: ContextEvent) => { const reminder = todoFocusReminder(store.getTodos()); if (!reminder) return; return { messages: [ ...event.messages, { role: "custom" as const, customType: "agent-todos-focus", content: reminder, display: false, timestamp: Date.now(), }, ], }; }); pi.registerTool({ name: TOOL_NAME, label: "Agent Todo Write", description: "Create or update the local agent task checklist shown above the editor. The in_progress item must match the work happening now; complete it only after its outcome is achieved and verified. Reopen it if later evidence shows more work is needed. Use merge=true for incremental updates by id; merge=false to replace (or clear with []). At most one in_progress item. Prefer this over Cursor native todo_write.", promptSnippet: TODO_WRITE_PROMPT_SNIPPET, promptGuidelines: TODO_WRITE_PROMPT_GUIDELINES, parameters: TodoWriteParams, executionMode: "sequential", async execute( _toolCallId: string, params: Static, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext, ) { const before = store.getTodos(); const validated = validateTodoWrite(before, params.todos, params.merge); if (!validated.ok) { const details: TodoWriteDetails = { action: "write", merge: params.merge, todos: before, counts: countTodos(before), error: validated.error, }; return { content: [{ type: "text", text: `Error: ${validated.error}` }], details, isError: true, }; } const hasNewOpenTodo = validated.todos.some( (todo) => (todo.status === "pending" || todo.status === "in_progress") && !before.some( (previous) => previous.id === todo.id && (previous.status === "pending" || previous.status === "in_progress"), ), ); if (hasNewOpenTodo) panelVisible = true; store.setTodos(validated.todos); updateUI(ctx); const counts = countTodos(validated.todos); const changes = summarizeChanges(before, validated.todos); const details: TodoWriteDetails = { action: "write", merge: params.merge, todos: validated.todos, counts, changes, }; return { content: [ { type: "text", text: formatTodosForModel(validated.todos, params.merge, counts), }, ], details, }; }, renderCall( args: Parameters[0], theme: Parameters[1], ) { return renderTodoCall(args, theme); }, renderResult( result: Parameters[0], { expanded }: { expanded: boolean }, theme: Parameters[2], ) { return renderTodoResult(result, expanded, theme); }, }); }