From d01d262749c79361a8676aedaafac7f315599c14 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 21:24:44 -0500 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20Limit=20TODOs=20to=207=20ite?= =?UTF-8?q?ms=20with=20precision=20gradient=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MAX_TODOS = 7 constant to enforce compact lists - Update tool description to teach 'high precision at center' model - Guide AI to summarize old completed and far future work - Add validation with educational error messages - Add visual styling: - Detect summary items via regex pattern: (N items|tasks|steps) - Apply italic + smaller font to summaries - Gradient fade for older completed items (exponential decay) - Add test coverage for MAX_TODOS validation - Encourage bidirectional flow: expand/condense as work progresses This keeps TODOs focused on what matters NOW (recent + current + immediate) while maintaining context through summarization. Generated with `cmux` --- src/components/TodoList.tsx | 71 +++++++++++++++++++++++++----- src/constants/toolLimits.ts | 2 + src/services/tools/todo.test.ts | 36 +++++++++++++++ src/services/tools/todo.ts | 15 +++++++ src/utils/tools/toolDefinitions.ts | 15 ++++++- 5 files changed, 125 insertions(+), 14 deletions(-) diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 3e3a9eaa3..c86af9955 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -9,7 +9,10 @@ const TodoListContainer = styled.div` padding: 6px 8px; `; -const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` +const TodoItemContainer = styled.div<{ + status: TodoItem["status"]; + isSummary?: boolean; +}>` display: flex; align-items: flex-start; gap: 6px; @@ -39,9 +42,10 @@ const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` }}; border-radius: 3px; font-family: var(--font-monospace); - font-size: 11px; + font-size: ${(props) => (props.isSummary ? "10px" : "11px")}; line-height: 1.35; color: var(--color-text); + font-style: ${(props) => (props.isSummary ? "italic" : "normal")}; `; const TodoIcon = styled.div` @@ -56,7 +60,12 @@ const TodoContent = styled.div` min-width: 0; `; -const TodoText = styled.div<{ status: TodoItem["status"] }>` +const TodoText = styled.div<{ + status: TodoItem["status"]; + completedIndex?: number; + totalCompleted?: number; + isSummary?: boolean; +}>` color: ${(props) => { switch (props.status) { case "completed": @@ -68,7 +77,21 @@ 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 + if (props.completedIndex !== undefined && + props.totalCompleted !== undefined && + props.totalCompleted > 2 && + props.completedIndex < props.totalCompleted - 2) { + // Fade older items more (exponential decay) + const recentIndex = props.totalCompleted - props.completedIndex; + return Math.max(0.35, 1 - (recentIndex * 0.15)); + } + return props.isSummary ? "0.5" : "0.7"; + } + return props.isSummary ? "0.75" : "1"; + }}; font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")}; white-space: nowrap; @@ -115,22 +138,46 @@ function getStatusIcon(status: TodoItem["status"]): string { } } +/** + * Detect if a TODO item is a summary based on content pattern. + * Matches patterns like: "(N items)", "(N tasks)", "(N steps)" + */ +function isSummaryItem(content: string): boolean { + return /\(\d+\s+(items?|tasks?|steps?)\)/i.test(content); +} + /** * Shared TODO list component used by: * - TodoToolCall (in expanded tool history) * - PinnedTodoList (pinned at bottom of chat) */ export const TodoList: React.FC = ({ todos }) => { + // Count completed items for fade effect + const completedCount = todos.filter((t) => t.status === "completed").length; + let completedIndex = 0; + return ( - {todos.map((todo, index) => ( - - {getStatusIcon(todo.status)} - - {todo.content} - - - ))} + {todos.map((todo, index) => { + const isSummary = isSummaryItem(todo.content); + const currentCompletedIndex = todo.status === "completed" ? completedIndex++ : 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'), " + From 00288769fae414bcd311e1511f3b8fb7804dcc12 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 21:25:54 -0500 Subject: [PATCH 2/5] Remove isSummary complexity - keep only fade effect Simplified visual treatment: - Removed summary item detection (regex pattern) - Removed italic styling and font-size changes for summaries - Keep gradient fade for old completed items - Fade effect still guides AI to condense old completions --- src/components/TodoList.tsx | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index c86af9955..43ce36f24 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -9,10 +9,7 @@ const TodoListContainer = styled.div` padding: 6px 8px; `; -const TodoItemContainer = styled.div<{ - status: TodoItem["status"]; - isSummary?: boolean; -}>` +const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` display: flex; align-items: flex-start; gap: 6px; @@ -42,10 +39,9 @@ const TodoItemContainer = styled.div<{ }}; border-radius: 3px; font-family: var(--font-monospace); - font-size: ${(props) => (props.isSummary ? "10px" : "11px")}; + font-size: 11px; line-height: 1.35; color: var(--color-text); - font-style: ${(props) => (props.isSummary ? "italic" : "normal")}; `; const TodoIcon = styled.div` @@ -64,7 +60,6 @@ const TodoText = styled.div<{ status: TodoItem["status"]; completedIndex?: number; totalCompleted?: number; - isSummary?: boolean; }>` color: ${(props) => { switch (props.status) { @@ -88,9 +83,9 @@ const TodoText = styled.div<{ const recentIndex = props.totalCompleted - props.completedIndex; return Math.max(0.35, 1 - (recentIndex * 0.15)); } - return props.isSummary ? "0.5" : "0.7"; + return "0.7"; } - return props.isSummary ? "0.75" : "1"; + return "1"; }}; font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")}; white-space: nowrap; @@ -138,14 +133,6 @@ function getStatusIcon(status: TodoItem["status"]): string { } } -/** - * Detect if a TODO item is a summary based on content pattern. - * Matches patterns like: "(N items)", "(N tasks)", "(N steps)" - */ -function isSummaryItem(content: string): boolean { - return /\(\d+\s+(items?|tasks?|steps?)\)/i.test(content); -} - /** * Shared TODO list component used by: * - TodoToolCall (in expanded tool history) @@ -159,18 +146,16 @@ export const TodoList: React.FC = ({ todos }) => { return ( {todos.map((todo, index) => { - const isSummary = isSummaryItem(todo.content); const currentCompletedIndex = todo.status === "completed" ? completedIndex++ : undefined; return ( - + {getStatusIcon(todo.status)} {todo.content} From 794bbca07ff3ea3f7bc751c9cac61a0bab3c8412 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 21:30:46 -0500 Subject: [PATCH 3/5] Add reverse gradient fade for future pending items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Far future items fade more (exponential decay) - First 2 pending items stay full opacity (immediate next steps) - Later pending items fade progressively (0.5 min opacity) - Mirrors the completed items fade (distant past ↔ distant future) - Reinforces 'high precision at center' visual model --- src/components/TodoList.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 43ce36f24..33f8d271f 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -60,6 +60,8 @@ const TodoText = styled.div<{ status: TodoItem["status"]; completedIndex?: number; totalCompleted?: number; + pendingIndex?: number; + totalPending?: number; }>` color: ${(props) => { switch (props.status) { @@ -74,7 +76,7 @@ const TodoText = styled.div<{ text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; opacity: ${(props) => { if (props.status === "completed") { - // Apply gradient fade for old completed items + // Apply gradient fade for old completed items (distant past) if (props.completedIndex !== undefined && props.totalCompleted !== undefined && props.totalCompleted > 2 && @@ -85,6 +87,17 @@ const TodoText = styled.div<{ } 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) { + // Fade later items more (exponential decay) + const futureDistance = props.pendingIndex - 1; + return Math.max(0.5, 1 - (futureDistance * 0.15)); + } + } return "1"; }}; font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")}; @@ -139,14 +152,17 @@ function getStatusIcon(status: TodoItem["status"]): string { * - PinnedTodoList (pinned at bottom of chat) */ export const TodoList: React.FC = ({ todos }) => { - // Count completed items for fade effect + // 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) => { const currentCompletedIndex = todo.status === "completed" ? completedIndex++ : undefined; + const currentPendingIndex = todo.status === "pending" ? pendingIndex++ : undefined; return ( @@ -156,6 +172,8 @@ export const TodoList: React.FC = ({ todos }) => { status={todo.status} completedIndex={currentCompletedIndex} totalCompleted={completedCount} + pendingIndex={currentPendingIndex} + totalPending={pendingCount} > {todo.content} From f8aaf73765dbb51ada493c9b5bcc2c6bb3f11b4f Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 21:31:25 -0500 Subject: [PATCH 4/5] DRY: Extract shared fade opacity calculation - Create calculateFadeOpacity() helper for exponential decay - Reduces duplication between completed/pending fade logic - Same behavior, cleaner code --- src/components/TodoList.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 33f8d271f..efbe006f6 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -56,6 +56,16 @@ const TodoContent = styled.div` min-width: 0; `; +/** + * 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; @@ -81,9 +91,8 @@ const TodoText = styled.div<{ props.totalCompleted !== undefined && props.totalCompleted > 2 && props.completedIndex < props.totalCompleted - 2) { - // Fade older items more (exponential decay) - const recentIndex = props.totalCompleted - props.completedIndex; - return Math.max(0.35, 1 - (recentIndex * 0.15)); + const distance = props.totalCompleted - props.completedIndex; + return calculateFadeOpacity(distance, 0.35); } return "0.7"; } @@ -93,9 +102,8 @@ const TodoText = styled.div<{ props.totalPending !== undefined && props.totalPending > 2 && props.pendingIndex > 1) { - // Fade later items more (exponential decay) - const futureDistance = props.pendingIndex - 1; - return Math.max(0.5, 1 - (futureDistance * 0.15)); + const distance = props.pendingIndex - 1; + return calculateFadeOpacity(distance, 0.5); } } return "1"; From ba798c1b4ee05f64722708df87eb78e97540a173 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 21:34:44 -0500 Subject: [PATCH 5/5] Fix formatting (prettier) --- src/components/TodoList.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index efbe006f6..941fe4f89 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -66,7 +66,7 @@ function calculateFadeOpacity(distance: number, minOpacity: number): number { return Math.max(minOpacity, 1 - distance * 0.15); } -const TodoText = styled.div<{ +const TodoText = styled.div<{ status: TodoItem["status"]; completedIndex?: number; totalCompleted?: number; @@ -87,10 +87,12 @@ const TodoText = styled.div<{ 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) { + 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); } @@ -98,10 +100,12 @@ const TodoText = styled.div<{ } 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) { + if ( + props.pendingIndex !== undefined && + props.totalPending !== undefined && + props.totalPending > 2 && + props.pendingIndex > 1 + ) { const distance = props.pendingIndex - 1; return calculateFadeOpacity(distance, 0.5); } @@ -176,7 +180,7 @@ export const TodoList: React.FC = ({ todos }) => { {getStatusIcon(todo.status)} -