diff --git a/src/core/prompts/tools/new-task.ts b/src/core/prompts/tools/new-task.ts index 7301b7b422..a8cfd9c5d2 100644 --- a/src/core/prompts/tools/new-task.ts +++ b/src/core/prompts/tools/new-task.ts @@ -7,11 +7,18 @@ Description: This will let you create a new task instance in the chosen mode usi Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- todos: (optional) A markdown checklist of todo items to initialize the new task with. Use the same format as update_todo_list. Usage: your-mode-slug-here Your initial instructions here + +[ ] First todo item +[ ] Second todo item +[x] Completed todo item +[-] In progress todo item + Example: @@ -19,5 +26,18 @@ Example: code Implement a new feature for the application. + +Example with todos: + +code +Implement user authentication + +[ ] Set up authentication middleware +[ ] Create login endpoint +[ ] Create logout endpoint +[ ] Add session management +[ ] Write tests for authentication + + ` } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fe8fd0f68f..c7e43db184 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -126,6 +126,7 @@ export type TaskOptions = { parentTask?: Task taskNumber?: number onCreated?: (cline: Task) => void + todos?: string } export class Task extends EventEmitter { @@ -228,6 +229,7 @@ export class Task extends EventEmitter { parentTask, taskNumber = -1, onCreated, + todos, }: TaskOptions) { super() @@ -294,6 +296,11 @@ export class Task extends EventEmitter { this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit) + // Parse and initialize todos if provided + if (todos !== undefined) { + this.parseTodosFromMarkdown(todos) + } + onCreated?.(this) if (startTask) { @@ -1956,4 +1963,42 @@ export class Task extends EventEmitter { public get cwd() { return this.workspacePath } + + // Todo parsing + private parseTodosFromMarkdown(todosMarkdown: string): void { + if (typeof todosMarkdown !== "string") { + return + } + + this.todoList = [] + + if (!todosMarkdown) { + return + } + + const lines = todosMarkdown + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + + for (const line of lines) { + const match = line.match(/^\[\s*([ xX\-~])\s*\]\s+(.+)$/) + if (!match) continue + + let status: "pending" | "in_progress" | "completed" = "pending" + if (match[1] === "x" || match[1] === "X") status = "completed" + else if (match[1] === "-" || match[1] === "~") status = "in_progress" + + const id = crypto + .createHash("md5") + .update(match[2] + status) + .digest("hex") + + this.todoList.push({ + id, + content: match[2], + status, + }) + } + } } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 9aa5a8d7a8..e941860a10 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -389,6 +389,157 @@ describe("Cline", () => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) }).toThrow("Either historyItem or task/images must be provided") }) + + it("should parse todos from markdown checklist when provided", () => { + const todosMarkdown = `[x] Design the architecture +[-] Write the code +[ ] Add tests +[ ] Update documentation` + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + todos: todosMarkdown, + startTask: false, + }) + + expect(task.todoList).toBeDefined() + expect(task.todoList).toHaveLength(4) + + expect(task.todoList![0].content).toBe("Design the architecture") + expect(task.todoList![0].status).toBe("completed") + + expect(task.todoList![1].content).toBe("Write the code") + expect(task.todoList![1].status).toBe("in_progress") + + expect(task.todoList![2].content).toBe("Add tests") + expect(task.todoList![2].status).toBe("pending") + + expect(task.todoList![3].content).toBe("Update documentation") + expect(task.todoList![3].status).toBe("pending") + }) + + it("should handle various markdown checklist formats", () => { + const todosMarkdown = `[X] Uppercase completed +[~] Alternative in-progress +[ ] Extra spaces +[x]No space after bracket` + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + todos: todosMarkdown, + startTask: false, + }) + + expect(task.todoList).toBeDefined() + expect(task.todoList).toHaveLength(3) // "No space after bracket" won't match the regex + + expect(task.todoList![0].content).toBe("Uppercase completed") + expect(task.todoList![0].status).toBe("completed") + + expect(task.todoList![1].content).toBe("Alternative in-progress") + expect(task.todoList![1].status).toBe("in_progress") + + expect(task.todoList![2].content).toBe("Extra spaces") + expect(task.todoList![2].status).toBe("pending") + }) + + it("should handle empty todos parameter", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + todos: "", + startTask: false, + }) + + expect(task.todoList).toBeDefined() + expect(task.todoList).toHaveLength(0) + }) + + it("should handle undefined todos parameter", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(task.todoList).toBeUndefined() + }) + + it("should handle todos with special characters", () => { + const todosMarkdown = `[x] Handle @mentions and $variables +[ ] Support | pipes and \\ backslashes +[-] Test "quotes" and 'apostrophes'` + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + todos: todosMarkdown, + startTask: false, + }) + + expect(task.todoList).toBeDefined() + expect(task.todoList).toHaveLength(3) + + expect(task.todoList![0].content).toBe("Handle @mentions and $variables") + expect(task.todoList![1].content).toBe("Support | pipes and \\ backslashes") + expect(task.todoList![2].content).toBe("Test \"quotes\" and 'apostrophes'") + }) + + it("should generate unique IDs for todos", () => { + const todosMarkdown = `[ ] First task +[ ] Second task +[ ] Third task` + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + todos: todosMarkdown, + startTask: false, + }) + + expect(task.todoList).toBeDefined() + expect(task.todoList).toHaveLength(3) + + // Check that all IDs are unique + const ids = task.todoList!.map((todo) => todo.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(3) + + // Check that IDs are valid MD5 hashes (32 hex characters) + ids.forEach((id) => { + expect(id).toMatch(/^[a-f0-9]{32}$/) + }) + }) + + it("should ignore invalid checklist lines", () => { + const todosMarkdown = `[x] Valid task +This is not a checklist item +- [ ] This format is not supported +[x] Another valid task +Random text` + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + todos: todosMarkdown, + startTask: false, + }) + + expect(task.todoList).toBeDefined() + expect(task.todoList).toHaveLength(2) + + expect(task.todoList![0].content).toBe("Valid task") + expect(task.todoList![1].content).toBe("Another valid task") + }) }) describe("getEnvironmentDetails", () => { diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 1dd79d6e98..a663139aa6 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -93,6 +93,7 @@ describe("newTaskTool", () => { "Review this: \\@file1.txt and also \\\\\\@file2.txt", // Unit Test Expectation: \\@ -> \@, \\\\@ -> \\\\@ undefined, mockCline, + { todos: undefined }, ) // Verify side effects @@ -126,6 +127,7 @@ describe("newTaskTool", () => { "This is already unescaped: \\@file1.txt", // Expected: \@ remains \@ undefined, mockCline, + { todos: undefined }, ) }) @@ -153,6 +155,7 @@ describe("newTaskTool", () => { "A normal mention @file1.txt", // Expected: @ remains @ undefined, mockCline, + { todos: undefined }, ) }) @@ -180,8 +183,136 @@ describe("newTaskTool", () => { "Mix: @file0.txt, \\@file1.txt, \\@file2.txt, \\\\\\@file3.txt", // Unit Test Expectation: @->@, \@->\@, \\@->\@, \\\\@->\\\\@ undefined, mockCline, + { todos: undefined }, ) }) + it("should pass todos parameter to initClineWithTask when provided", async () => { + const block: ToolUse = { + type: "tool_use", + name: "new_task", + params: { + mode: "code", + message: "Implement a new feature", + todos: "[x] Design the architecture\n[-] Write the code\n[ ] Add tests\n[ ] Update documentation", + }, + partial: false, + } + + await newTaskTool( + mockCline as any, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(mockInitClineWithTask).toHaveBeenCalledWith("Implement a new feature", undefined, mockCline, { + todos: "[x] Design the architecture\n[-] Write the code\n[ ] Add tests\n[ ] Update documentation", + }) + }) + + it("should handle todos parameter with various markdown checklist formats", async () => { + const block: ToolUse = { + type: "tool_use", + name: "new_task", + params: { + mode: "code", + message: "Complex task", + todos: "[X] Completed uppercase\n[~] Alternative in-progress\n[ ] Pending with extra spaces\n[x]No space after bracket", + }, + partial: false, + } + + await newTaskTool( + mockCline as any, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(mockInitClineWithTask).toHaveBeenCalledWith("Complex task", undefined, mockCline, { + todos: "[X] Completed uppercase\n[~] Alternative in-progress\n[ ] Pending with extra spaces\n[x]No space after bracket", + }) + }) + + it("should work without todos parameter (backward compatibility)", async () => { + const block: ToolUse = { + type: "tool_use", + name: "new_task", + params: { + mode: "code", + message: "Task without todos", + }, + partial: false, + } + + await newTaskTool( + mockCline as any, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should be called without the todos in options + expect(mockInitClineWithTask).toHaveBeenCalledWith("Task without todos", undefined, mockCline, {}) + }) + + it("should handle empty todos parameter", async () => { + const block: ToolUse = { + type: "tool_use", + name: "new_task", + params: { + mode: "code", + message: "Task with empty todos", + todos: "", + }, + partial: false, + } + + await newTaskTool( + mockCline as any, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Empty string should still be passed + expect(mockInitClineWithTask).toHaveBeenCalledWith("Task with empty todos", undefined, mockCline, { todos: "" }) + }) + + it("should handle todos with special characters and escaping", async () => { + const block: ToolUse = { + type: "tool_use", + name: "new_task", + params: { + mode: "code", + message: "Task with special todos", + todos: "[x] Handle \\@mentions in todos\n[ ] Support | pipes and \\\\ backslashes\n[-] Test \"quotes\" and 'apostrophes'", + }, + partial: false, + } + + await newTaskTool( + mockCline as any, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(mockInitClineWithTask).toHaveBeenCalledWith("Task with special todos", undefined, mockCline, { + todos: "[x] Handle \\@mentions in todos\n[ ] Support | pipes and \\\\ backslashes\n[-] Test \"quotes\" and 'apostrophes'", + }) + }) + // Add more tests for error handling (missing params, invalid mode, approval denied) if needed }) diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index 7cc7063b49..d54aa5428c 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -16,6 +16,7 @@ export async function newTaskTool( ) { const mode: string | undefined = block.params.mode const message: string | undefined = block.params.message + const todos: string | undefined = block.params.todos try { if (block.partial) { @@ -23,6 +24,7 @@ export async function newTaskTool( tool: "newTask", mode: removeClosingTag("mode", mode), content: removeClosingTag("message", message), + todos: removeClosingTag("todos", todos), }) await cline.ask("tool", partialMessage, block.partial).catch(() => {}) @@ -59,6 +61,7 @@ export async function newTaskTool( tool: "newTask", mode: targetMode.name, content: message, + todos: todos, }) const didApprove = await askApproval("tool", toolMessage) @@ -86,7 +89,7 @@ export async function newTaskTool( // Delay to allow mode change to take effect before next tool is executed. await delay(500) - const newCline = await provider.initClineWithTask(unescapedMessage, undefined, cline) + const newCline = await provider.initClineWithTask(unescapedMessage, undefined, cline, { todos }) if (!newCline) { pushToolResult(t("tools:newTask.errors.policy_restriction")) return diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6bcb85e337..4154435bd1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -534,7 +534,7 @@ export class ClineProvider TaskOptions, "enableDiff" | "enableCheckpoints" | "fuzzyMatchThreshold" | "consecutiveMistakeLimit" | "experiments" > - > = {}, + > & { todos?: string } = {}, ) { const { apiConfiguration, @@ -549,6 +549,8 @@ export class ClineProvider throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) } + const { todos, ...taskOptions } = options + const cline = new Task({ provider: this, apiConfiguration, @@ -563,7 +565,8 @@ export class ClineProvider parentTask, taskNumber: this.clineStack.length + 1, onCreated: (cline) => this.emit("clineCreated", cline), - ...options, + todos, + ...taskOptions, }) await this.addClineToStack(cline) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 67972243fe..047c2fe351 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -155,7 +155,7 @@ export interface SwitchModeToolUse extends ToolUse { export interface NewTaskToolUse extends ToolUse { name: "new_task" - params: Partial, "mode" | "message">> + params: Partial, "mode" | "message" | "todos">> } export interface SearchAndReplaceToolUse extends ToolUse {