diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 3e3a9eaa3..941fe4f89 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -56,7 +56,23 @@ const TodoContent = styled.div` min-width: 0; `; -const TodoText = styled.div<{ status: TodoItem["status"] }>` +/** + * Calculate opacity fade for items distant from the center (exponential decay). + * @param distance - How far from the center (higher = more fade) + * @param minOpacity - Minimum opacity floor + * @returns Opacity value between minOpacity and 1.0 + */ +function calculateFadeOpacity(distance: number, minOpacity: number): number { + return Math.max(minOpacity, 1 - distance * 0.15); +} + +const TodoText = styled.div<{ + status: TodoItem["status"]; + completedIndex?: number; + totalCompleted?: number; + pendingIndex?: number; + totalPending?: number; +}>` color: ${(props) => { switch (props.status) { case "completed": @@ -68,7 +84,34 @@ const TodoText = styled.div<{ status: TodoItem["status"] }>` } }}; text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; - opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")}; + opacity: ${(props) => { + if (props.status === "completed") { + // Apply gradient fade for old completed items (distant past) + if ( + props.completedIndex !== undefined && + props.totalCompleted !== undefined && + props.totalCompleted > 2 && + props.completedIndex < props.totalCompleted - 2 + ) { + const distance = props.totalCompleted - props.completedIndex; + return calculateFadeOpacity(distance, 0.35); + } + return "0.7"; + } + if (props.status === "pending") { + // Apply gradient fade for far future pending items (distant future) + if ( + props.pendingIndex !== undefined && + props.totalPending !== undefined && + props.totalPending > 2 && + props.pendingIndex > 1 + ) { + const distance = props.pendingIndex - 1; + return calculateFadeOpacity(distance, 0.5); + } + } + return "1"; + }}; font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")}; white-space: nowrap; @@ -121,16 +164,35 @@ function getStatusIcon(status: TodoItem["status"]): string { * - PinnedTodoList (pinned at bottom of chat) */ export const TodoList: React.FC = ({ todos }) => { + // Count completed and pending items for fade effects + const completedCount = todos.filter((t) => t.status === "completed").length; + const pendingCount = todos.filter((t) => t.status === "pending").length; + let completedIndex = 0; + let pendingIndex = 0; + return ( - {todos.map((todo, index) => ( - - {getStatusIcon(todo.status)} - - {todo.content} - - - ))} + {todos.map((todo, index) => { + const currentCompletedIndex = todo.status === "completed" ? completedIndex++ : undefined; + const currentPendingIndex = todo.status === "pending" ? pendingIndex++ : undefined; + + return ( + + {getStatusIcon(todo.status)} + + + {todo.content} + + + + ); + })} ); }; diff --git a/src/constants/toolLimits.ts b/src/constants/toolLimits.ts index 1df0602bb..5d937536a 100644 --- a/src/constants/toolLimits.ts +++ b/src/constants/toolLimits.ts @@ -3,3 +3,5 @@ export const BASH_DEFAULT_MAX_LINES = 300; export const BASH_HARD_MAX_LINES = 300; export const BASH_MAX_LINE_BYTES = 1024; // 1KB per line export const BASH_MAX_TOTAL_BYTES = 16 * 1024; // 16KB total output + +export const MAX_TODOS = 7; // Maximum number of TODO items in a list diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts index 848bda3a3..f343d578d 100644 --- a/src/services/tools/todo.test.ts +++ b/src/services/tools/todo.test.ts @@ -95,6 +95,42 @@ describe("Todo Storage", () => { expect(storedTodos).toEqual([]); }); + it("should reject when exceeding MAX_TODOS limit", async () => { + // Create a list with 8 items (exceeds MAX_TODOS = 7) + const tooManyTodos: TodoItem[] = [ + { content: "Task 1", status: "completed" }, + { content: "Task 2", status: "completed" }, + { content: "Task 3", status: "completed" }, + { content: "Task 4", status: "completed" }, + { content: "Task 5", status: "in_progress" }, + { content: "Task 6", status: "pending" }, + { content: "Task 7", status: "pending" }, + { content: "Task 8", status: "pending" }, + ]; + + await expect(setTodosForTempDir(tempDir, tooManyTodos)).rejects.toThrow( + /Too many TODOs \(8\/7\)/i + ); + await expect(setTodosForTempDir(tempDir, tooManyTodos)).rejects.toThrow( + /Keep high precision at the center/i + ); + }); + + it("should accept exactly MAX_TODOS items", async () => { + const maxTodos: TodoItem[] = [ + { content: "Old work (2 tasks)", status: "completed" }, + { content: "Recent task", status: "completed" }, + { content: "Current work", status: "in_progress" }, + { content: "Next step 1", status: "pending" }, + { content: "Next step 2", status: "pending" }, + { content: "Next step 3", status: "pending" }, + { content: "Future work (5 items)", status: "pending" }, + ]; + + await setTodosForTempDir(tempDir, maxTodos); + expect(await getTodosForTempDir(tempDir)).toEqual(maxTodos); + }); + it("should reject multiple in_progress tasks", async () => { const validTodos: TodoItem[] = [ { diff --git a/src/services/tools/todo.ts b/src/services/tools/todo.ts index 4875fa13b..aedb96cf9 100644 --- a/src/services/tools/todo.ts +++ b/src/services/tools/todo.ts @@ -4,6 +4,7 @@ import * as path from "path"; import type { ToolFactory } from "@/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; import type { TodoItem } from "@/types/tools"; +import { MAX_TODOS } from "@/constants/toolLimits"; /** * Get path to todos.json file in the stream's temporary directory @@ -29,6 +30,7 @@ async function readTodos(tempDir: string): Promise { /** * Validate todo sequencing rules before persisting. * Enforces order: completed → in_progress → pending (top to bottom) + * Enforces maximum count to encourage summarization. */ function validateTodos(todos: TodoItem[]): void { if (!Array.isArray(todos)) { @@ -39,6 +41,19 @@ function validateTodos(todos: TodoItem[]): void { return; } + // Enforce maximum TODO count + if (todos.length > MAX_TODOS) { + throw new Error( + `Too many TODOs (${todos.length}/${MAX_TODOS}). ` + + `Keep high precision at the center: ` + + `summarize old completed work (e.g., 'Setup phase (3 tasks)'), ` + + `keep recent completions detailed (1-2), ` + + `one in_progress, ` + + `immediate pending detailed (2-3), ` + + `and summarize far future work (e.g., 'Testing phase (4 items)').` + ); + } + let phase: "completed" | "in_progress" | "pending" = "completed"; let inProgressCount = 0; diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 14b33a2c2..36288f17e 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -154,12 +154,23 @@ export const TOOL_DEFINITIONS = { }, todo_write: { description: - "Create or update the todo list for tracking multi-step tasks. " + + "Create or update the todo list for tracking multi-step tasks (limit: 7 items). " + "Use this for ALL complex, multi-step plans to keep the user informed of progress. " + "Replace the entire list on each call - the AI should track which tasks are completed. " + + "\n\n" + + "Structure the list with high precision at the center:\n" + + "- Old completed work: Summarize into 1 overview item (e.g., 'Set up project infrastructure (4 tasks)')\n" + + "- Recent completions: Keep detailed (last 1-2 items)\n" + + "- Current work: One in_progress item with clear description\n" + + "- Immediate next steps: Detailed pending items (next 2-3 actions)\n" + + "- Far future work: Summarize into phase items (e.g., 'Testing and polish (3 items)')\n" + + "\n" + + "Update frequently as work progresses. As tasks complete, older completions should be " + + "condensed to make room. Similarly, summarized future work expands into detailed items " + + "as it becomes immediate. " + + "\n\n" + "Mark ONE task as in_progress at a time. " + "Order tasks as: completed first, then in_progress (max 1), then pending last. " + - "Update frequently as work progresses to provide visibility into ongoing operations. " + "Before finishing your response, ensure all todos are marked as completed. " + "Use appropriate tense in content: past tense for completed (e.g., 'Added tests'), " + "present progressive for in_progress (e.g., 'Adding tests'), " +