From 1b950c5bf920d05da1ddd310cc9eb4ee32eac548 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Oct 2025 19:54:27 -0500 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=A4=96=20Add=20TODO=20list=20featur?= =?UTF-8?q?e=20to=20track=20multi-step=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TodoWrite and TodoRead tools for managing task lists during operations - Schema includes content, status (pending/in_progress/completed), and activeForm fields - In-memory storage with workspace isolation (ephemeral, conversation-scoped) - UI component displays checklist with visual indicators (✓/⏳/○) - Full-replacement semantics (replaces entire list on each call) - Unit tests for storage layer Generated with `cmux` --- src/components/Messages/ToolMessage.tsx | 20 +++ src/components/tools/TodoToolCall.tsx | 131 +++++++++++++++++ src/services/tools/todo.test.ts | 187 ++++++++++++++++++++++++ src/services/tools/todo.ts | 79 ++++++++++ src/types/tools.ts | 20 +++ src/utils/tools/toolDefinitions.ts | 24 +++ src/utils/tools/tools.ts | 3 + 7 files changed, 464 insertions(+) create mode 100644 src/components/tools/TodoToolCall.tsx create mode 100644 src/services/tools/todo.test.ts create mode 100644 src/services/tools/todo.ts diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index c6db6f560..ec2e2875a 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -6,6 +6,7 @@ import { BashToolCall } from "../tools/BashToolCall"; import { FileEditToolCall } from "../tools/FileEditToolCall"; import { FileReadToolCall } from "../tools/FileReadToolCall"; import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; +import { TodoToolCall } from "../tools/TodoToolCall"; import type { BashToolArgs, BashToolResult, @@ -19,6 +20,8 @@ import type { FileEditReplaceLinesToolResult, ProposePlanToolArgs, ProposePlanToolResult, + TodoWriteToolArgs, + TodoWriteToolResult, } from "@/types/tools"; interface ToolMessageProps { @@ -65,6 +68,11 @@ function isProposePlanTool(toolName: string, args: unknown): args is ProposePlan return TOOL_DEFINITIONS.propose_plan.schema.safeParse(args).success; } +function isTodoWriteTool(toolName: string, args: unknown): args is TodoWriteToolArgs { + if (toolName !== "todo_write") return false; + return TOOL_DEFINITIONS.todo_write.schema.safeParse(args).success; +} + export const ToolMessage: React.FC = ({ message, className, workspaceId }) => { // Route to specialized components based on tool name if (isBashTool(message.toolName, message.args)) { @@ -144,6 +152,18 @@ export const ToolMessage: React.FC = ({ message, className, wo ); } + if (isTodoWriteTool(message.toolName, message.args)) { + return ( +
+ +
+ ); + } + // Fallback to generic tool call return (
diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx new file mode 100644 index 000000000..9820d1f13 --- /dev/null +++ b/src/components/tools/TodoToolCall.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { TodoWriteToolArgs, TodoWriteToolResult, TodoItem } from "@/types/tools"; +import { + ToolContainer, + ToolHeader, + ExpandIcon, + ToolName, + StatusIndicator, + ToolDetails, +} from "./shared/ToolPrimitives"; +import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; + +const TodoList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; +`; + +const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + background: ${(props) => { + switch (props.status) { + case "completed": + return "color-mix(in srgb, #4caf50, transparent 90%)"; + case "in_progress": + return "color-mix(in srgb, #2196f3, transparent 90%)"; + case "pending": + default: + return "color-mix(in srgb, #888, transparent 95%)"; + } + }}; + border-left: 3px solid + ${(props) => { + switch (props.status) { + case "completed": + return "#4caf50"; + case "in_progress": + return "#2196f3"; + case "pending": + default: + return "#888"; + } + }}; + border-radius: 4px; + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.5; + color: var(--color-text); +`; + +const TodoIcon = styled.div` + font-size: 14px; + flex-shrink: 0; + margin-top: 2px; +`; + +const TodoContent = styled.div` + flex: 1; +`; + +const TodoText = styled.div<{ status: TodoItem["status"] }>` + color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")}; + text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; +`; + +const TodoActiveForm = styled.div` + color: #2196f3; + font-weight: 500; + margin-top: 2px; +`; + +interface TodoToolCallProps { + args: TodoWriteToolArgs; + result?: TodoWriteToolResult; + status?: ToolStatus; +} + +function getStatusIcon(status: TodoItem["status"]): string { + switch (status) { + case "completed": + return "✓"; + case "in_progress": + return "⏳"; + case "pending": + default: + return "○"; + } +} + +export const TodoToolCall: React.FC = ({ + args, + result: _result, + status = "pending", +}) => { + const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default + const statusDisplay = getStatusDisplay(status); + + return ( + + + + todo_write + {statusDisplay} + + + {expanded && ( + + + {args.todos.map((todo, index) => ( + + {getStatusIcon(todo.status)} + + {todo.content} + {todo.status === "in_progress" && ( + {todo.activeForm} + )} + + + ))} + + + )} + + ); +}; + diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts new file mode 100644 index 000000000..16829d30d --- /dev/null +++ b/src/services/tools/todo.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach } from "@jest/globals"; +import { + clearTodosForWorkspace, + getTodosForWorkspace, + setTodosForWorkspace, +} from "./todo"; +import type { TodoItem } from "@/types/tools"; + +describe("Todo Storage", () => { + const workspaceId = "test-workspace"; + + beforeEach(() => { + // Clear todos before each test + clearTodosForWorkspace(workspaceId); + }); + + describe("setTodosForWorkspace", () => { + it("should store todo list for workspace", () => { + const todos: TodoItem[] = [ + { + content: "Install dependencies", + status: "completed", + activeForm: "Installing dependencies", + }, + { + content: "Write tests", + status: "in_progress", + activeForm: "Writing tests", + }, + { + content: "Update documentation", + status: "pending", + activeForm: "Updating documentation", + }, + ]; + + setTodosForWorkspace(workspaceId, todos); + + const storedTodos = getTodosForWorkspace(workspaceId); + expect(storedTodos).toEqual(todos); + }); + + it("should replace entire todo list on update", () => { + // Create initial list + const initialTodos: TodoItem[] = [ + { + content: "Task 1", + status: "pending", + activeForm: "Doing task 1", + }, + { + content: "Task 2", + status: "pending", + activeForm: "Doing task 2", + }, + ]; + + setTodosForWorkspace(workspaceId, initialTodos); + + // Replace with updated list + const updatedTodos: TodoItem[] = [ + { + content: "Task 1", + status: "completed", + activeForm: "Doing task 1", + }, + { + content: "Task 2", + status: "in_progress", + activeForm: "Doing task 2", + }, + { + content: "Task 3", + status: "pending", + activeForm: "Doing task 3", + }, + ]; + + setTodosForWorkspace(workspaceId, updatedTodos); + + // Verify list was replaced, not merged + const storedTodos = getTodosForWorkspace(workspaceId); + expect(storedTodos).toEqual(updatedTodos); + }); + + it("should handle empty todo list", () => { + // Create initial list + setTodosForWorkspace(workspaceId, [ + { + content: "Task 1", + status: "pending", + activeForm: "Doing task 1", + }, + ]); + + // Clear list + setTodosForWorkspace(workspaceId, []); + + const storedTodos = getTodosForWorkspace(workspaceId); + expect(storedTodos).toEqual([]); + }); + }); + + describe("getTodosForWorkspace", () => { + it("should return empty array when no todos exist", () => { + const todos = getTodosForWorkspace(workspaceId); + expect(todos).toEqual([]); + }); + + it("should return current todo list", () => { + const todos: TodoItem[] = [ + { + content: "Task 1", + status: "completed", + activeForm: "Doing task 1", + }, + { + content: "Task 2", + status: "in_progress", + activeForm: "Doing task 2", + }, + ]; + + setTodosForWorkspace(workspaceId, todos); + + const retrievedTodos = getTodosForWorkspace(workspaceId); + expect(retrievedTodos).toEqual(todos); + }); + }); + + describe("workspace isolation", () => { + it("should isolate todos between workspaces", () => { + const workspace1Id = "workspace-1"; + const workspace2Id = "workspace-2"; + + // Create different todos in each workspace + const todos1: TodoItem[] = [ + { + content: "Workspace 1 task", + status: "pending", + activeForm: "Working on workspace 1", + }, + ]; + + const todos2: TodoItem[] = [ + { + content: "Workspace 2 task", + status: "pending", + activeForm: "Working on workspace 2", + }, + ]; + + setTodosForWorkspace(workspace1Id, todos1); + setTodosForWorkspace(workspace2Id, todos2); + + // Verify each workspace has its own todos + const retrievedTodos1 = getTodosForWorkspace(workspace1Id); + const retrievedTodos2 = getTodosForWorkspace(workspace2Id); + + expect(retrievedTodos1).toEqual(todos1); + expect(retrievedTodos2).toEqual(todos2); + + // Clean up + clearTodosForWorkspace(workspace1Id); + clearTodosForWorkspace(workspace2Id); + }); + }); + + describe("clearTodosForWorkspace", () => { + it("should clear todos for specific workspace", () => { + const todos: TodoItem[] = [ + { + content: "Task 1", + status: "pending", + activeForm: "Doing task 1", + }, + ]; + + setTodosForWorkspace(workspaceId, todos); + expect(getTodosForWorkspace(workspaceId)).toEqual(todos); + + clearTodosForWorkspace(workspaceId); + expect(getTodosForWorkspace(workspaceId)).toEqual([]); + }); + }); +}); + diff --git a/src/services/tools/todo.ts b/src/services/tools/todo.ts new file mode 100644 index 000000000..e6a52f1e4 --- /dev/null +++ b/src/services/tools/todo.ts @@ -0,0 +1,79 @@ +import { tool } from "ai"; +import type { ToolFactory } from "@/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; +import type { TodoItem } from "@/types/tools"; + +// In-memory storage: Map +const todoStore = new Map(); + +/** + * Extract workspace ID from cwd path + * Expected format: ~/.cmux/src// + */ +function getWorkspaceIdFromCwd(cwd: string): string { + const parts = cwd.split("/"); + const srcIndex = parts.findIndex((p) => p === "src"); + if (srcIndex === -1 || srcIndex + 2 >= parts.length) { + throw new Error(`Invalid workspace path: ${cwd}`); + } + return parts[srcIndex + 2]; // workspace_id is after project_name +} + +/** + * Todo write tool factory + * Creates a tool that allows the AI to create/update the todo list + */ +export const createTodoWriteTool: ToolFactory = (config) => { + return tool({ + description: TOOL_DEFINITIONS.todo_write.description, + inputSchema: TOOL_DEFINITIONS.todo_write.schema, + execute: ({ todos }) => { + const workspaceId = getWorkspaceIdFromCwd(config.cwd); + todoStore.set(workspaceId, todos); + return Promise.resolve({ + success: true as const, + count: todos.length, + }); + }, + }); +}; + +/** + * Todo read tool factory + * Creates a tool that allows the AI to read the current todo list + */ +export const createTodoReadTool: ToolFactory = (config) => { + return tool({ + description: TOOL_DEFINITIONS.todo_read.description, + inputSchema: TOOL_DEFINITIONS.todo_read.schema, + execute: () => { + const workspaceId = getWorkspaceIdFromCwd(config.cwd); + const todos = todoStore.get(workspaceId) ?? []; + return Promise.resolve({ + todos, + }); + }, + }); +}; + +/** + * Set todos for a workspace (useful for testing) + */ +export function setTodosForWorkspace(workspaceId: string, todos: TodoItem[]): void { + todoStore.set(workspaceId, todos); +} + +/** + * Get todos for a workspace (useful for testing) + */ +export function getTodosForWorkspace(workspaceId: string): TodoItem[] { + return todoStore.get(workspaceId) ?? []; +} + +/** + * Clear todos for a workspace (useful for testing and cleanup) + */ +export function clearTodosForWorkspace(workspaceId: string): void { + todoStore.delete(workspaceId); +} + diff --git a/src/types/tools.ts b/src/types/tools.ts index 2e7adb20c..df069ee2f 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -136,3 +136,23 @@ export interface ProposePlanToolResult { plan: string; message: string; } + +// Todo Tool Types +export interface TodoItem { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm: string; +} + +export interface TodoWriteToolArgs { + todos: TodoItem[]; +} + +export interface TodoWriteToolResult { + success: true; + count: number; +} + +export interface TodoReadToolResult { + todos: TodoItem[]; +} diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index efcd725e3..7ead9549d 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -152,6 +152,28 @@ export const TOOL_DEFINITIONS = { ), }), }, + todo_write: { + description: + "Create or update the todo list for tracking multi-step tasks. " + + "Use this to track progress through complex operations. " + + "Replace the entire list on each call - the AI should track which tasks are completed. " + + "Mark ONE task as in_progress at a time.", + schema: z.object({ + todos: z.array( + z.object({ + content: z.string().describe("Task description"), + status: z.enum(["pending", "in_progress", "completed"]).describe("Task status"), + activeForm: z + .string() + .describe("Present progressive form of the task (e.g., 'Adding tests')"), + }) + ), + }), + }, + todo_read: { + description: "Read the current todo list", + schema: z.object({}), + }, } as const; /** @@ -190,6 +212,8 @@ export function getAvailableTools(modelString: string): string[] { "file_edit_insert", "propose_plan", "compact_summary", + "todo_write", + "todo_read", ]; // Add provider-specific tools diff --git a/src/utils/tools/tools.ts b/src/utils/tools/tools.ts index aa84b29e2..a2130ea25 100644 --- a/src/utils/tools/tools.ts +++ b/src/utils/tools/tools.ts @@ -6,6 +6,7 @@ import { createFileEditReplaceStringTool } from "@/services/tools/file_edit_repl import { createFileEditInsertTool } from "@/services/tools/file_edit_insert"; import { createProposePlanTool } from "@/services/tools/propose_plan"; import { createCompactSummaryTool } from "@/services/tools/compact_summary"; +import { createTodoWriteTool, createTodoReadTool } from "@/services/tools/todo"; import { log } from "@/services/log"; /** @@ -56,6 +57,8 @@ export async function getToolsForModel( bash: createBashTool(config), propose_plan: createProposePlanTool(config), compact_summary: createCompactSummaryTool(config), + todo_write: createTodoWriteTool(config), + todo_read: createTodoReadTool(config), }; // Try to add provider-specific web search tools if available From bf86404fa87fd4119d9d0d6628725a9b832887ac Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Oct 2025 20:12:33 -0500 Subject: [PATCH 02/22] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/tools/TodoToolCall.tsx | 1 - src/services/tools/todo.test.ts | 7 +------ src/services/tools/todo.ts | 1 - 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index 9820d1f13..1454d8e93 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -128,4 +128,3 @@ export const TodoToolCall: React.FC = ({ ); }; - diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts index 16829d30d..7473c204d 100644 --- a/src/services/tools/todo.test.ts +++ b/src/services/tools/todo.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, beforeEach } from "@jest/globals"; -import { - clearTodosForWorkspace, - getTodosForWorkspace, - setTodosForWorkspace, -} from "./todo"; +import { clearTodosForWorkspace, getTodosForWorkspace, setTodosForWorkspace } from "./todo"; import type { TodoItem } from "@/types/tools"; describe("Todo Storage", () => { @@ -184,4 +180,3 @@ describe("Todo Storage", () => { }); }); }); - diff --git a/src/services/tools/todo.ts b/src/services/tools/todo.ts index e6a52f1e4..8ab4e8a50 100644 --- a/src/services/tools/todo.ts +++ b/src/services/tools/todo.ts @@ -76,4 +76,3 @@ export function getTodosForWorkspace(workspaceId: string): TodoItem[] { export function clearTodosForWorkspace(workspaceId: string): void { todoStore.delete(workspaceId); } - From 00f30107fec961f35aa6b8b8d541ebcc94851aed Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 09:02:29 -0500 Subject: [PATCH 03/22] =?UTF-8?q?=F0=9F=A4=96=20Refactor=20TODO=20storage?= =?UTF-8?q?=20to=20use=20stream=20tmpdir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Replace in-memory Map storage with filesystem storage in stream's tmpdir - TODOs are now automatically cleaned up when stream ends - Each stream has isolated TODO storage (no cross-stream pollution) - Test helper functions updated to use tempDir instead of workspaceId Benefits: - Fixes memory leaks (TODOs no longer survive across conversations) - Proper stream isolation (concurrent streams don't share TODOs) - Automatic lifecycle management via streamManager cleanup - TODOs are inspectable during debugging (stored as todos.json) _Generated with `cmux`_ --- src/services/tools/todo.test.ts | 140 +++++++++++++++++--------------- src/services/tools/todo.ts | 76 ++++++++++------- 2 files changed, 122 insertions(+), 94 deletions(-) diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts index 7473c204d..cc1a96c5b 100644 --- a/src/services/tools/todo.test.ts +++ b/src/services/tools/todo.test.ts @@ -1,17 +1,25 @@ -import { describe, it, expect, beforeEach } from "@jest/globals"; -import { clearTodosForWorkspace, getTodosForWorkspace, setTodosForWorkspace } from "./todo"; +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { clearTodosForTempDir, getTodosForTempDir, setTodosForTempDir } from "./todo"; import type { TodoItem } from "@/types/tools"; describe("Todo Storage", () => { - const workspaceId = "test-workspace"; + let tempDir: string; - beforeEach(() => { - // Clear todos before each test - clearTodosForWorkspace(workspaceId); + beforeEach(async () => { + // Create a temporary directory for each test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "todo-test-")); }); - describe("setTodosForWorkspace", () => { - it("should store todo list for workspace", () => { + afterEach(async () => { + // Clean up temporary directory after each test + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe("setTodosForTempDir", () => { + it("should store todo list in temp directory", async () => { const todos: TodoItem[] = [ { content: "Install dependencies", @@ -30,13 +38,13 @@ describe("Todo Storage", () => { }, ]; - setTodosForWorkspace(workspaceId, todos); + await setTodosForTempDir(tempDir, todos); - const storedTodos = getTodosForWorkspace(workspaceId); + const storedTodos = await getTodosForTempDir(tempDir); expect(storedTodos).toEqual(todos); }); - it("should replace entire todo list on update", () => { + it("should replace entire todo list on update", async () => { // Create initial list const initialTodos: TodoItem[] = [ { @@ -51,7 +59,7 @@ describe("Todo Storage", () => { }, ]; - setTodosForWorkspace(workspaceId, initialTodos); + await setTodosForTempDir(tempDir, initialTodos); // Replace with updated list const updatedTodos: TodoItem[] = [ @@ -72,16 +80,16 @@ describe("Todo Storage", () => { }, ]; - setTodosForWorkspace(workspaceId, updatedTodos); + await setTodosForTempDir(tempDir, updatedTodos); // Verify list was replaced, not merged - const storedTodos = getTodosForWorkspace(workspaceId); + const storedTodos = await getTodosForTempDir(tempDir); expect(storedTodos).toEqual(updatedTodos); }); - it("should handle empty todo list", () => { + it("should handle empty todo list", async () => { // Create initial list - setTodosForWorkspace(workspaceId, [ + await setTodosForTempDir(tempDir, [ { content: "Task 1", status: "pending", @@ -90,20 +98,20 @@ describe("Todo Storage", () => { ]); // Clear list - setTodosForWorkspace(workspaceId, []); + await setTodosForTempDir(tempDir, []); - const storedTodos = getTodosForWorkspace(workspaceId); + const storedTodos = await getTodosForTempDir(tempDir); expect(storedTodos).toEqual([]); }); }); - describe("getTodosForWorkspace", () => { - it("should return empty array when no todos exist", () => { - const todos = getTodosForWorkspace(workspaceId); + describe("getTodosForTempDir", () => { + it("should return empty array when no todos exist", async () => { + const todos = await getTodosForTempDir(tempDir); expect(todos).toEqual([]); }); - it("should return current todo list", () => { + it("should return current todo list", async () => { const todos: TodoItem[] = [ { content: "Task 1", @@ -117,53 +125,55 @@ describe("Todo Storage", () => { }, ]; - setTodosForWorkspace(workspaceId, todos); + await setTodosForTempDir(tempDir, todos); - const retrievedTodos = getTodosForWorkspace(workspaceId); + const retrievedTodos = await getTodosForTempDir(tempDir); expect(retrievedTodos).toEqual(todos); }); }); - describe("workspace isolation", () => { - it("should isolate todos between workspaces", () => { - const workspace1Id = "workspace-1"; - const workspace2Id = "workspace-2"; - - // Create different todos in each workspace - const todos1: TodoItem[] = [ - { - content: "Workspace 1 task", - status: "pending", - activeForm: "Working on workspace 1", - }, - ]; - - const todos2: TodoItem[] = [ - { - content: "Workspace 2 task", - status: "pending", - activeForm: "Working on workspace 2", - }, - ]; - - setTodosForWorkspace(workspace1Id, todos1); - setTodosForWorkspace(workspace2Id, todos2); - - // Verify each workspace has its own todos - const retrievedTodos1 = getTodosForWorkspace(workspace1Id); - const retrievedTodos2 = getTodosForWorkspace(workspace2Id); - - expect(retrievedTodos1).toEqual(todos1); - expect(retrievedTodos2).toEqual(todos2); - - // Clean up - clearTodosForWorkspace(workspace1Id); - clearTodosForWorkspace(workspace2Id); + describe("stream isolation", () => { + it("should isolate todos between different temp directories", async () => { + const tempDir1 = await fs.mkdtemp(path.join(os.tmpdir(), "todo-test-1-")); + const tempDir2 = await fs.mkdtemp(path.join(os.tmpdir(), "todo-test-2-")); + + try { + // Create different todos in each temp directory + const todos1: TodoItem[] = [ + { + content: "Stream 1 task", + status: "pending", + activeForm: "Working on stream 1", + }, + ]; + + const todos2: TodoItem[] = [ + { + content: "Stream 2 task", + status: "pending", + activeForm: "Working on stream 2", + }, + ]; + + await setTodosForTempDir(tempDir1, todos1); + await setTodosForTempDir(tempDir2, todos2); + + // Verify each temp directory has its own todos + const retrievedTodos1 = await getTodosForTempDir(tempDir1); + const retrievedTodos2 = await getTodosForTempDir(tempDir2); + + expect(retrievedTodos1).toEqual(todos1); + expect(retrievedTodos2).toEqual(todos2); + } finally { + // Clean up + await fs.rm(tempDir1, { recursive: true, force: true }); + await fs.rm(tempDir2, { recursive: true, force: true }); + } }); }); - describe("clearTodosForWorkspace", () => { - it("should clear todos for specific workspace", () => { + describe("clearTodosForTempDir", () => { + it("should clear todos for specific temp directory", async () => { const todos: TodoItem[] = [ { content: "Task 1", @@ -172,11 +182,11 @@ describe("Todo Storage", () => { }, ]; - setTodosForWorkspace(workspaceId, todos); - expect(getTodosForWorkspace(workspaceId)).toEqual(todos); + await setTodosForTempDir(tempDir, todos); + expect(await getTodosForTempDir(tempDir)).toEqual(todos); - clearTodosForWorkspace(workspaceId); - expect(getTodosForWorkspace(workspaceId)).toEqual([]); + await clearTodosForTempDir(tempDir); + expect(await getTodosForTempDir(tempDir)).toEqual([]); }); }); }); diff --git a/src/services/tools/todo.ts b/src/services/tools/todo.ts index 8ab4e8a50..6ebaf8f00 100644 --- a/src/services/tools/todo.ts +++ b/src/services/tools/todo.ts @@ -1,22 +1,37 @@ import { tool } from "ai"; +import * as fs from "fs/promises"; +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"; -// In-memory storage: Map -const todoStore = new Map(); +/** + * Get path to todos.json file in the stream's temporary directory + */ +function getTodoFilePath(tempDir: string): string { + return path.join(tempDir, "todos.json"); +} /** - * Extract workspace ID from cwd path - * Expected format: ~/.cmux/src// + * Read todos from filesystem */ -function getWorkspaceIdFromCwd(cwd: string): string { - const parts = cwd.split("/"); - const srcIndex = parts.findIndex((p) => p === "src"); - if (srcIndex === -1 || srcIndex + 2 >= parts.length) { - throw new Error(`Invalid workspace path: ${cwd}`); +async function readTodos(tempDir: string): Promise { + const todoFile = getTodoFilePath(tempDir); + try { + const content = await fs.readFile(todoFile, "utf-8"); + return JSON.parse(content) as TodoItem[]; + } catch { + // File doesn't exist yet or is invalid + return []; } - return parts[srcIndex + 2]; // workspace_id is after project_name +} + +/** + * Write todos to filesystem + */ +async function writeTodos(tempDir: string, todos: TodoItem[]): Promise { + const todoFile = getTodoFilePath(tempDir); + await fs.writeFile(todoFile, JSON.stringify(todos, null, 2), "utf-8"); } /** @@ -27,13 +42,12 @@ export const createTodoWriteTool: ToolFactory = (config) => { return tool({ description: TOOL_DEFINITIONS.todo_write.description, inputSchema: TOOL_DEFINITIONS.todo_write.schema, - execute: ({ todos }) => { - const workspaceId = getWorkspaceIdFromCwd(config.cwd); - todoStore.set(workspaceId, todos); - return Promise.resolve({ + execute: async ({ todos }) => { + await writeTodos(config.tempDir, todos); + return { success: true as const, count: todos.length, - }); + }; }, }); }; @@ -46,33 +60,37 @@ export const createTodoReadTool: ToolFactory = (config) => { return tool({ description: TOOL_DEFINITIONS.todo_read.description, inputSchema: TOOL_DEFINITIONS.todo_read.schema, - execute: () => { - const workspaceId = getWorkspaceIdFromCwd(config.cwd); - const todos = todoStore.get(workspaceId) ?? []; - return Promise.resolve({ + execute: async () => { + const todos = await readTodos(config.tempDir); + return { todos, - }); + }; }, }); }; /** - * Set todos for a workspace (useful for testing) + * Set todos for a temp directory (useful for testing) */ -export function setTodosForWorkspace(workspaceId: string, todos: TodoItem[]): void { - todoStore.set(workspaceId, todos); +export async function setTodosForTempDir(tempDir: string, todos: TodoItem[]): Promise { + await writeTodos(tempDir, todos); } /** - * Get todos for a workspace (useful for testing) + * Get todos for a temp directory (useful for testing) */ -export function getTodosForWorkspace(workspaceId: string): TodoItem[] { - return todoStore.get(workspaceId) ?? []; +export async function getTodosForTempDir(tempDir: string): Promise { + return readTodos(tempDir); } /** - * Clear todos for a workspace (useful for testing and cleanup) + * Clear todos for a temp directory (useful for testing and cleanup) */ -export function clearTodosForWorkspace(workspaceId: string): void { - todoStore.delete(workspaceId); +export async function clearTodosForTempDir(tempDir: string): Promise { + const todoFile = getTodoFilePath(tempDir); + try { + await fs.unlink(todoFile); + } catch { + // File doesn't exist, nothing to clear + } } From 6d7466a1b8bf0b27c017d6b3072c9391e2dc2f95 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 12:51:46 -0500 Subject: [PATCH 04/22] =?UTF-8?q?=F0=9F=A4=96=20Add=20TODO=20sequencing=20?= =?UTF-8?q?validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce ordering rules for todo_write tool: - TODOs must be ordered: pending → in_progress → completed - Only one task can be in_progress at a time - Empty lists are allowed (all completed or nothing to do) Changes: - Added validateTodos() function with state machine validation - writeTodos() now validates before persisting to disk - Updated all tests to use valid todo ordering - Added 4 new test cases for validation rules Benefits: - Prevents model from creating invalid TODO states - Clear error messages guide model to fix ordering - Validation happens before filesystem write (atomic) _Generated with `cmux`_ --- src/services/tools/todo.test.ts | 122 +++++++++++++++++++++++++----- src/services/tools/todo.ts | 54 +++++++++++++ src/stores/GitStatusStore.test.ts | 31 ++++++++ 3 files changed, 190 insertions(+), 17 deletions(-) diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts index cc1a96c5b..fbf732042 100644 --- a/src/services/tools/todo.test.ts +++ b/src/services/tools/todo.test.ts @@ -22,9 +22,9 @@ describe("Todo Storage", () => { it("should store todo list in temp directory", async () => { const todos: TodoItem[] = [ { - content: "Install dependencies", - status: "completed", - activeForm: "Installing dependencies", + content: "Update documentation", + status: "pending", + activeForm: "Updating documentation", }, { content: "Write tests", @@ -32,9 +32,9 @@ describe("Todo Storage", () => { activeForm: "Writing tests", }, { - content: "Update documentation", - status: "pending", - activeForm: "Updating documentation", + content: "Install dependencies", + status: "completed", + activeForm: "Installing dependencies", }, ]; @@ -64,9 +64,9 @@ describe("Todo Storage", () => { // Replace with updated list const updatedTodos: TodoItem[] = [ { - content: "Task 1", - status: "completed", - activeForm: "Doing task 1", + content: "Task 3", + status: "pending", + activeForm: "Doing task 3", }, { content: "Task 2", @@ -74,9 +74,9 @@ describe("Todo Storage", () => { activeForm: "Doing task 2", }, { - content: "Task 3", - status: "pending", - activeForm: "Doing task 3", + content: "Task 1", + status: "completed", + activeForm: "Doing task 1", }, ]; @@ -103,6 +103,94 @@ describe("Todo Storage", () => { const storedTodos = await getTodosForTempDir(tempDir); expect(storedTodos).toEqual([]); }); + + it("should reject multiple in_progress tasks", async () => { + const validTodos: TodoItem[] = [ + { + content: "Step 1", + status: "pending", + activeForm: "Handling step 1", + }, + ]; + + await setTodosForTempDir(tempDir, validTodos); + + const invalidTodos: TodoItem[] = [ + { + content: "Step 1", + status: "in_progress", + activeForm: "Handling step 1", + }, + { + content: "Step 2", + status: "in_progress", + activeForm: "Handling step 2", + }, + ]; + + await expect(setTodosForTempDir(tempDir, invalidTodos)).rejects.toThrow( + /only one task can be marked as in_progress/i + ); + + // Original todos should remain unchanged on failure + expect(await getTodosForTempDir(tempDir)).toEqual(validTodos); + }); + + it("should reject when pending tasks appear after in_progress", async () => { + const invalidTodos: TodoItem[] = [ + { + content: "Step 1", + status: "in_progress", + activeForm: "Handling step 1", + }, + { + content: "Step 2", + status: "pending", + activeForm: "Handling step 2", + }, + ]; + + await expect(setTodosForTempDir(tempDir, invalidTodos)).rejects.toThrow( + /pending tasks must appear before in-progress or completed tasks/i + ); + }); + + it("should reject when in_progress tasks appear after completed", async () => { + const invalidTodos: TodoItem[] = [ + { + content: "Step 1", + status: "completed", + activeForm: "Handling step 1", + }, + { + content: "Step 2", + status: "in_progress", + activeForm: "Handling step 2", + }, + ]; + + await expect(setTodosForTempDir(tempDir, invalidTodos)).rejects.toThrow( + /in-progress tasks must appear before completed tasks/i + ); + }); + + it("should allow all completed tasks without in_progress", async () => { + const todos: TodoItem[] = [ + { + content: "Step 1", + status: "completed", + activeForm: "Handling step 1", + }, + { + content: "Step 2", + status: "completed", + activeForm: "Handling step 2", + }, + ]; + + await setTodosForTempDir(tempDir, todos); + expect(await getTodosForTempDir(tempDir)).toEqual(todos); + }); }); describe("getTodosForTempDir", () => { @@ -113,16 +201,16 @@ describe("Todo Storage", () => { it("should return current todo list", async () => { const todos: TodoItem[] = [ - { - content: "Task 1", - status: "completed", - activeForm: "Doing task 1", - }, { content: "Task 2", status: "in_progress", activeForm: "Doing task 2", }, + { + content: "Task 1", + status: "completed", + activeForm: "Doing task 1", + }, ]; await setTodosForTempDir(tempDir, todos); diff --git a/src/services/tools/todo.ts b/src/services/tools/todo.ts index 6ebaf8f00..8de15db3e 100644 --- a/src/services/tools/todo.ts +++ b/src/services/tools/todo.ts @@ -26,10 +26,64 @@ async function readTodos(tempDir: string): Promise { } } +/** + * Validate todo sequencing rules before persisting. + */ +function validateTodos(todos: TodoItem[]): void { + if (!Array.isArray(todos)) { + throw new Error("Invalid todos payload: expected an array"); + } + + let phase: "pending" | "in_progress" | "completed" = "pending"; + let inProgressCount = 0; + + if (todos.length === 0) { + return; + } + + todos.forEach((todo, index) => { + const status = todo.status; + + switch (status) { + case "pending": { + if (phase !== "pending") { + throw new Error( + `Invalid todo order at index ${index}: pending tasks must appear before in-progress or completed tasks` + ); + } + break; + } + case "in_progress": { + if (phase === "completed") { + throw new Error( + `Invalid todo order at index ${index}: in-progress tasks must appear before completed tasks` + ); + } + inProgressCount += 1; + if (inProgressCount > 1) { + throw new Error( + "Invalid todo list: only one task can be marked as in_progress at a time" + ); + } + phase = "in_progress"; + break; + } + case "completed": { + phase = "completed"; + break; + } + default: { + throw new Error(`Invalid todo status at index ${index}: ${status}`); + } + } + }); +} + /** * Write todos to filesystem */ async function writeTodos(tempDir: string, todos: TodoItem[]): Promise { + validateTodos(todos); const todoFile = getTodoFilePath(tempDir); await fs.writeFile(todoFile, JSON.stringify(todos, null, 2), "utf-8"); } diff --git a/src/stores/GitStatusStore.test.ts b/src/stores/GitStatusStore.test.ts index 77b77786d..6f17644e0 100644 --- a/src/stores/GitStatusStore.test.ts +++ b/src/stores/GitStatusStore.test.ts @@ -1,3 +1,7 @@ +import type { Result } from "@/types/result"; +import type { BashToolResult } from "@/types/tools"; + +import { describe, it, test, expect, beforeEach, afterEach, jest } from "@jest/globals"; import { GitStatusStore } from "./GitStatusStore"; import type { WorkspaceMetadata } from "@/types/workspace"; @@ -13,15 +17,42 @@ import type { WorkspaceMetadata } from "@/types/workspace"; * - Cleanup on dispose */ +const mockExecuteBash: jest.Mock< + Promise>, + [string, string, { timeout_secs?: number; niceness?: number }?] +> = jest.fn(); + describe("GitStatusStore", () => { let store: GitStatusStore; beforeEach(() => { + mockExecuteBash.mockReset(); + mockExecuteBash.mockResolvedValue({ + success: true as const, + data: { + success: true as const, + output: "", + exitCode: 0 as const, + wall_duration_ms: 0, + }, + }); + + (globalThis as unknown as { window: unknown }).window = { + api: { + workspace: { + executeBash: mockExecuteBash, + }, + }, + } as unknown as Window & typeof globalThis; + store = new GitStatusStore(); }); afterEach(() => { store.dispose(); + // Cleanup mocked window to avoid leaking between tests + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (globalThis as { window?: unknown }).window; }); test("subscribe and unsubscribe", () => { From 5cf2de1f87810626ad2f299a9b61286982ee32f2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 12:53:14 -0500 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=A4=96=20Fix=20GitStatusStore=20tes?= =?UTF-8?q?t=20type=20annotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove invalid type parameters from jest.fn() mock - Jest's Mock type only accepts 0-1 type arguments in TypeScript, not the tuple syntax used previously. _Generated with `cmux`_ --- src/stores/GitStatusStore.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/stores/GitStatusStore.test.ts b/src/stores/GitStatusStore.test.ts index 6f17644e0..de6184de8 100644 --- a/src/stores/GitStatusStore.test.ts +++ b/src/stores/GitStatusStore.test.ts @@ -17,10 +17,9 @@ import type { WorkspaceMetadata } from "@/types/workspace"; * - Cleanup on dispose */ -const mockExecuteBash: jest.Mock< - Promise>, - [string, string, { timeout_secs?: number; niceness?: number }?] -> = jest.fn(); +const mockExecuteBash = jest.fn< + () => Promise> +>(); describe("GitStatusStore", () => { let store: GitStatusStore; @@ -28,14 +27,14 @@ describe("GitStatusStore", () => { beforeEach(() => { mockExecuteBash.mockReset(); mockExecuteBash.mockResolvedValue({ - success: true as const, + success: true, data: { - success: true as const, + success: true, output: "", - exitCode: 0 as const, + exitCode: 0, wall_duration_ms: 0, }, - }); + } as Result); (globalThis as unknown as { window: unknown }).window = { api: { From 31492e75979d63ed67219bc9eb9cbfbd1a2ea1c1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 12:55:34 -0500 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=A4=96=20Fix=20lint=20error:=20cast?= =?UTF-8?q?=20status=20to=20string=20in=20error=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with `cmux`_ --- src/services/tools/todo.ts | 2 +- src/stores/GitStatusStore.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/services/tools/todo.ts b/src/services/tools/todo.ts index 8de15db3e..f1b0c7065 100644 --- a/src/services/tools/todo.ts +++ b/src/services/tools/todo.ts @@ -73,7 +73,7 @@ function validateTodos(todos: TodoItem[]): void { break; } default: { - throw new Error(`Invalid todo status at index ${index}: ${status}`); + throw new Error(`Invalid todo status at index ${index}: ${String(status)}`); } } }); diff --git a/src/stores/GitStatusStore.test.ts b/src/stores/GitStatusStore.test.ts index de6184de8..a9485785c 100644 --- a/src/stores/GitStatusStore.test.ts +++ b/src/stores/GitStatusStore.test.ts @@ -17,9 +17,7 @@ import type { WorkspaceMetadata } from "@/types/workspace"; * - Cleanup on dispose */ -const mockExecuteBash = jest.fn< - () => Promise> ->(); +const mockExecuteBash = jest.fn<() => Promise>>(); describe("GitStatusStore", () => { let store: GitStatusStore; From 476f40053e3a00788f472498dc60ee28509dd617 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 14:39:39 -0500 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=A4=96=20Make=20TODO=20display=20mo?= =?UTF-8?q?re=20compact=20and=20elegant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduced vertical spacing: gap 8px→3px, padding 8px→4px - Tighter line-height: 1.5→1.35 - Smaller font size: 12px→11px (activeForm 10px) - Thinner border-left: 3px→2px - Subtler backgrounds (92%/96% transparency vs 90%/95%) - Added opacity to completed items for visual hierarchy - Refined icon and text sizes for density --- src/components/tools/TodoToolCall.tsx | 35 +++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index 1454d8e93..71b0fec6d 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -14,27 +14,27 @@ import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/to const TodoList = styled.div` display: flex; flex-direction: column; - gap: 8px; - padding: 8px; + gap: 3px; + padding: 6px 8px; `; const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` display: flex; align-items: flex-start; - gap: 8px; - padding: 8px 12px; + gap: 6px; + padding: 4px 8px; background: ${(props) => { switch (props.status) { case "completed": - return "color-mix(in srgb, #4caf50, transparent 90%)"; + return "color-mix(in srgb, #4caf50, transparent 92%)"; case "in_progress": - return "color-mix(in srgb, #2196f3, transparent 90%)"; + return "color-mix(in srgb, #2196f3, transparent 92%)"; case "pending": default: - return "color-mix(in srgb, #888, transparent 95%)"; + return "color-mix(in srgb, #888, transparent 96%)"; } }}; - border-left: 3px solid + border-left: 2px solid ${(props) => { switch (props.status) { case "completed": @@ -43,35 +43,40 @@ const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` return "#2196f3"; case "pending": default: - return "#888"; + return "#666"; } }}; - border-radius: 4px; + border-radius: 3px; font-family: var(--font-monospace); - font-size: 12px; - line-height: 1.5; + font-size: 11px; + line-height: 1.35; color: var(--color-text); `; const TodoIcon = styled.div` - font-size: 14px; + font-size: 12px; flex-shrink: 0; - margin-top: 2px; + margin-top: 1px; + opacity: 0.8; `; const TodoContent = styled.div` flex: 1; + min-width: 0; `; const TodoText = styled.div<{ status: TodoItem["status"] }>` color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")}; text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; + opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")}; `; const TodoActiveForm = styled.div` color: #2196f3; font-weight: 500; - margin-top: 2px; + margin-top: 1px; + font-size: 10px; + opacity: 0.9; `; interface TodoToolCallProps { From a14aa13b5657df91a6c36cb1a23ddbc1f8df5cac Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 14:44:41 -0500 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=A4=96=20Show=20only=20activeForm?= =?UTF-8?q?=20for=20in=5Fprogress=20tasks=20with=20animated=20ellipsis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In-progress tasks now display activeForm instead of content - Added CSS animation for ellipsis (...) on activeForm - Animation cycles through '', '.', '..', '...' every 1.5s - Increased activeForm font size to 11px (matches other text) - Removed margin-top since activeForm is now the primary text --- src/components/tools/TodoToolCall.tsx | 32 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index 71b0fec6d..d15a3e7cd 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -74,9 +74,30 @@ const TodoText = styled.div<{ status: TodoItem["status"] }>` const TodoActiveForm = styled.div` color: #2196f3; font-weight: 500; - margin-top: 1px; - font-size: 10px; - opacity: 0.9; + font-size: 11px; + opacity: 0.95; + + &::after { + content: "..."; + display: inline-block; + width: 1.2em; + animation: ellipsis 1.5s infinite; + } + + @keyframes ellipsis { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + } `; interface TodoToolCallProps { @@ -120,9 +141,10 @@ export const TodoToolCall: React.FC = ({ {getStatusIcon(todo.status)} - {todo.content} - {todo.status === "in_progress" && ( + {todo.status === "in_progress" ? ( {todo.activeForm} + ) : ( + {todo.content} )} From f98ce29879e723fadf011ffc498740531e31b2e9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 14:45:31 -0500 Subject: [PATCH 09/22] =?UTF-8?q?=F0=9F=A4=96=20Fix=20ellipsis=20animation?= =?UTF-8?q?=20causing=20line=20breaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed animation from content change to width animation - Uses overflow: hidden with steps(4, end) for smooth dots reveal - Fixed width prevents layout shifts and line breaks - Ellipsis animates from 0 width to 1em, showing dots progressively --- src/components/tools/TodoToolCall.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index d15a3e7cd..e0d8b8c94 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -76,26 +76,23 @@ const TodoActiveForm = styled.div` font-weight: 500; font-size: 11px; opacity: 0.95; + display: inline-block; &::after { content: "..."; display: inline-block; - width: 1.2em; - animation: ellipsis 1.5s infinite; + width: 1em; + overflow: hidden; + vertical-align: bottom; + animation: ellipsis 1.5s steps(4, end) infinite; } @keyframes ellipsis { 0% { - content: ""; + width: 0; } - 25% { - content: "."; - } - 50% { - content: ".."; - } - 75% { - content: "..."; + 100% { + width: 1em; } } `; From 48938563b511dcc3045ecc110d4e92c9906cbd9c Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 14:50:55 -0500 Subject: [PATCH 10/22] =?UTF-8?q?=F0=9F=A4=96=20Reverse=20TODO=20order=20t?= =?UTF-8?q?o=20completed=20=E2=86=92=20in=5Fprogress=20=E2=86=92=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Validation now enforces completed first, in_progress middle, pending last - Updated all test fixtures to match new order - Updated tool description to emphasize using TODOs for all complex multi-step operations - Fixed ellipsis animation to prevent line breaks (width animation with overflow:hidden) - In-progress tasks now show only activeForm (not content) with animated ellipsis This creates a more natural progress view: what's done → what's happening → what's next --- src/components/tools/TodoToolCall.tsx | 18 ++++++---- src/services/tools/todo.test.ts | 50 +++++++++++++-------------- src/services/tools/todo.ts | 24 +++++++------ src/utils/tools/toolDefinitions.ts | 6 ++-- 4 files changed, 54 insertions(+), 44 deletions(-) diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index e0d8b8c94..dcd64071c 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -76,23 +76,27 @@ const TodoActiveForm = styled.div` font-weight: 500; font-size: 11px; opacity: 0.95; - display: inline-block; + white-space: nowrap; &::after { content: "..."; - display: inline-block; - width: 1em; + display: inline; overflow: hidden; - vertical-align: bottom; animation: ellipsis 1.5s steps(4, end) infinite; } @keyframes ellipsis { 0% { - width: 0; + content: ""; } - 100% { - width: 1em; + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; } } `; diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts index fbf732042..aa7b6f7df 100644 --- a/src/services/tools/todo.test.ts +++ b/src/services/tools/todo.test.ts @@ -22,9 +22,9 @@ describe("Todo Storage", () => { it("should store todo list in temp directory", async () => { const todos: TodoItem[] = [ { - content: "Update documentation", - status: "pending", - activeForm: "Updating documentation", + content: "Install dependencies", + status: "completed", + activeForm: "Installing dependencies", }, { content: "Write tests", @@ -32,9 +32,9 @@ describe("Todo Storage", () => { activeForm: "Writing tests", }, { - content: "Install dependencies", - status: "completed", - activeForm: "Installing dependencies", + content: "Update documentation", + status: "pending", + activeForm: "Updating documentation", }, ]; @@ -64,9 +64,9 @@ describe("Todo Storage", () => { // Replace with updated list const updatedTodos: TodoItem[] = [ { - content: "Task 3", - status: "pending", - activeForm: "Doing task 3", + content: "Task 1", + status: "completed", + activeForm: "Doing task 1", }, { content: "Task 2", @@ -74,9 +74,9 @@ describe("Todo Storage", () => { activeForm: "Doing task 2", }, { - content: "Task 1", - status: "completed", - activeForm: "Doing task 1", + content: "Task 3", + status: "pending", + activeForm: "Doing task 3", }, ]; @@ -136,41 +136,41 @@ describe("Todo Storage", () => { expect(await getTodosForTempDir(tempDir)).toEqual(validTodos); }); - it("should reject when pending tasks appear after in_progress", async () => { + it("should reject when in_progress tasks appear after pending", async () => { const invalidTodos: TodoItem[] = [ { content: "Step 1", - status: "in_progress", + status: "pending", activeForm: "Handling step 1", }, { content: "Step 2", - status: "pending", + status: "in_progress", activeForm: "Handling step 2", }, ]; await expect(setTodosForTempDir(tempDir, invalidTodos)).rejects.toThrow( - /pending tasks must appear before in-progress or completed tasks/i + /in-progress tasks must appear before pending tasks/i ); }); - it("should reject when in_progress tasks appear after completed", async () => { + it("should reject when completed tasks appear after in_progress", async () => { const invalidTodos: TodoItem[] = [ { content: "Step 1", - status: "completed", + status: "in_progress", activeForm: "Handling step 1", }, { content: "Step 2", - status: "in_progress", + status: "completed", activeForm: "Handling step 2", }, ]; await expect(setTodosForTempDir(tempDir, invalidTodos)).rejects.toThrow( - /in-progress tasks must appear before completed tasks/i + /completed tasks must appear before in-progress or pending tasks/i ); }); @@ -201,16 +201,16 @@ describe("Todo Storage", () => { it("should return current todo list", async () => { const todos: TodoItem[] = [ - { - content: "Task 2", - status: "in_progress", - activeForm: "Doing task 2", - }, { content: "Task 1", status: "completed", activeForm: "Doing task 1", }, + { + content: "Task 2", + status: "in_progress", + activeForm: "Doing task 2", + }, ]; await setTodosForTempDir(tempDir, todos); diff --git a/src/services/tools/todo.ts b/src/services/tools/todo.ts index f1b0c7065..4875fa13b 100644 --- a/src/services/tools/todo.ts +++ b/src/services/tools/todo.ts @@ -28,35 +28,37 @@ async function readTodos(tempDir: string): Promise { /** * Validate todo sequencing rules before persisting. + * Enforces order: completed → in_progress → pending (top to bottom) */ function validateTodos(todos: TodoItem[]): void { if (!Array.isArray(todos)) { throw new Error("Invalid todos payload: expected an array"); } - let phase: "pending" | "in_progress" | "completed" = "pending"; - let inProgressCount = 0; - if (todos.length === 0) { return; } + let phase: "completed" | "in_progress" | "pending" = "completed"; + let inProgressCount = 0; + todos.forEach((todo, index) => { const status = todo.status; switch (status) { - case "pending": { - if (phase !== "pending") { + case "completed": { + if (phase !== "completed") { throw new Error( - `Invalid todo order at index ${index}: pending tasks must appear before in-progress or completed tasks` + `Invalid todo order at index ${index}: completed tasks must appear before in-progress or pending tasks` ); } + // Stay in completed phase break; } case "in_progress": { - if (phase === "completed") { + if (phase === "pending") { throw new Error( - `Invalid todo order at index ${index}: in-progress tasks must appear before completed tasks` + `Invalid todo order at index ${index}: in-progress tasks must appear before pending tasks` ); } inProgressCount += 1; @@ -65,11 +67,13 @@ function validateTodos(todos: TodoItem[]): void { "Invalid todo list: only one task can be marked as in_progress at a time" ); } + // Transition to in_progress phase (from completed or stay in in_progress) phase = "in_progress"; break; } - case "completed": { - phase = "completed"; + case "pending": { + // Transition to pending phase (from completed, in_progress, or stay in pending) + phase = "pending"; break; } default: { diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 7ead9549d..db8bc9caf 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -155,9 +155,11 @@ export const TOOL_DEFINITIONS = { todo_write: { description: "Create or update the todo list for tracking multi-step tasks. " + - "Use this to track progress through complex operations. " + + "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. " + - "Mark ONE task as in_progress at a time.", + "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.", schema: z.object({ todos: z.array( z.object({ From b80066f02e0269af09e6eb0634c3146cabf109e4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 14:57:23 -0500 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=A4=96=20Add=20=F0=9F=93=8B=20emoji?= =?UTF-8?q?=20and=20complete=20tool=20description=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use TooltipWrapper pattern for emoji display (matches other tools) - Removed ToolName component in favor of tooltip - Added instruction to mark all todos completed before finishing stream - Ensures consistency with bash, file_read, etc. tool displays --- src/components/tools/TodoToolCall.tsx | 7 +++++-- src/utils/tools/toolDefinitions.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index dcd64071c..4a06abf7c 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -5,11 +5,11 @@ import { ToolContainer, ToolHeader, ExpandIcon, - ToolName, StatusIndicator, ToolDetails, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; const TodoList = styled.div` display: flex; @@ -131,7 +131,10 @@ export const TodoToolCall: React.FC = ({ - todo_write + + 📋 + todo_write + {statusDisplay} diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index db8bc9caf..875a6a849 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -159,7 +159,8 @@ export const TOOL_DEFINITIONS = { "Replace the entire list on each call - the AI should track which tasks are completed. " + "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.", + "Update frequently as work progresses to provide visibility into ongoing operations. " + + "Before finishing the stream, ensure all todos are marked as completed.", schema: z.object({ todos: z.array( z.object({ From 1ffdaf8672668db182a69395f6be65058e44c89c Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:25:36 -0500 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=A4=96=20Extract=20TODO=20list=20re?= =?UTF-8?q?ndering=20to=20shared=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created TodoList.tsx with all styling and rendering logic - TodoToolCall now uses shared component - Changed default expansion to false (collapsed by default) - Keeps historical record when expanded - Prepares for PinnedTodoList component reuse --- src/components/TodoList.tsx | 134 ++++++++++++++++++++++++++ src/components/tools/TodoToolCall.tsx | 123 +---------------------- 2 files changed, 138 insertions(+), 119 deletions(-) create mode 100644 src/components/TodoList.tsx diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..5d572d013 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,134 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { TodoItem } from "@/types/tools"; + +const TodoListContainer = styled.div` + display: flex; + flex-direction: column; + gap: 3px; + padding: 6px 8px; +`; + +const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` + display: flex; + align-items: flex-start; + gap: 6px; + padding: 4px 8px; + background: ${(props) => { + switch (props.status) { + case "completed": + return "color-mix(in srgb, #4caf50, transparent 92%)"; + case "in_progress": + return "color-mix(in srgb, #2196f3, transparent 92%)"; + case "pending": + default: + return "color-mix(in srgb, #888, transparent 96%)"; + } + }}; + border-left: 2px solid + ${(props) => { + switch (props.status) { + case "completed": + return "#4caf50"; + case "in_progress": + return "#2196f3"; + case "pending": + default: + return "#666"; + } + }}; + border-radius: 3px; + font-family: var(--font-monospace); + font-size: 11px; + line-height: 1.35; + color: var(--color-text); +`; + +const TodoIcon = styled.div` + font-size: 12px; + flex-shrink: 0; + margin-top: 1px; + opacity: 0.8; +`; + +const TodoContent = styled.div` + flex: 1; + min-width: 0; +`; + +const TodoText = styled.div<{ status: TodoItem["status"] }>` + color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")}; + text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; + opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")}; +`; + +const TodoActiveForm = styled.div` + color: #2196f3; + font-weight: 500; + font-size: 11px; + opacity: 0.95; + white-space: nowrap; + + &::after { + content: "..."; + display: inline; + overflow: hidden; + animation: ellipsis 1.5s steps(4, end) infinite; + } + + @keyframes ellipsis { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + } +`; + +interface TodoListProps { + todos: TodoItem[]; +} + +function getStatusIcon(status: TodoItem["status"]): string { + switch (status) { + case "completed": + return "✓"; + case "in_progress": + return "⏳"; + case "pending": + default: + return "○"; + } +} + +/** + * Shared TODO list component used by: + * - TodoToolCall (in expanded tool history) + * - PinnedTodoList (pinned at bottom of chat) + */ +export const TodoList: React.FC = ({ todos }) => { + return ( + + {todos.map((todo, index) => ( + + {getStatusIcon(todo.status)} + + {todo.status === "in_progress" ? ( + {todo.activeForm} + ) : ( + {todo.content} + )} + + + ))} + + ); +}; + diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index 4a06abf7c..68404e829 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -1,6 +1,5 @@ import React from "react"; -import styled from "@emotion/styled"; -import type { TodoWriteToolArgs, TodoWriteToolResult, TodoItem } from "@/types/tools"; +import type { TodoWriteToolArgs, TodoWriteToolResult } from "@/types/tools"; import { ToolContainer, ToolHeader, @@ -10,96 +9,7 @@ import { } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; import { TooltipWrapper, Tooltip } from "../Tooltip"; - -const TodoList = styled.div` - display: flex; - flex-direction: column; - gap: 3px; - padding: 6px 8px; -`; - -const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` - display: flex; - align-items: flex-start; - gap: 6px; - padding: 4px 8px; - background: ${(props) => { - switch (props.status) { - case "completed": - return "color-mix(in srgb, #4caf50, transparent 92%)"; - case "in_progress": - return "color-mix(in srgb, #2196f3, transparent 92%)"; - case "pending": - default: - return "color-mix(in srgb, #888, transparent 96%)"; - } - }}; - border-left: 2px solid - ${(props) => { - switch (props.status) { - case "completed": - return "#4caf50"; - case "in_progress": - return "#2196f3"; - case "pending": - default: - return "#666"; - } - }}; - border-radius: 3px; - font-family: var(--font-monospace); - font-size: 11px; - line-height: 1.35; - color: var(--color-text); -`; - -const TodoIcon = styled.div` - font-size: 12px; - flex-shrink: 0; - margin-top: 1px; - opacity: 0.8; -`; - -const TodoContent = styled.div` - flex: 1; - min-width: 0; -`; - -const TodoText = styled.div<{ status: TodoItem["status"] }>` - color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")}; - text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; - opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")}; -`; - -const TodoActiveForm = styled.div` - color: #2196f3; - font-weight: 500; - font-size: 11px; - opacity: 0.95; - white-space: nowrap; - - &::after { - content: "..."; - display: inline; - overflow: hidden; - animation: ellipsis 1.5s steps(4, end) infinite; - } - - @keyframes ellipsis { - 0% { - content: ""; - } - 25% { - content: "."; - } - 50% { - content: ".."; - } - 75% { - content: "..."; - } - } -`; +import { TodoList } from "../TodoList"; interface TodoToolCallProps { args: TodoWriteToolArgs; @@ -107,24 +17,12 @@ interface TodoToolCallProps { status?: ToolStatus; } -function getStatusIcon(status: TodoItem["status"]): string { - switch (status) { - case "completed": - return "✓"; - case "in_progress": - return "⏳"; - case "pending": - default: - return "○"; - } -} - export const TodoToolCall: React.FC = ({ args, result: _result, status = "pending", }) => { - const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default + const { expanded, toggleExpanded } = useToolExpansion(false); // Collapsed by default const statusDisplay = getStatusDisplay(status); return ( @@ -140,20 +38,7 @@ export const TodoToolCall: React.FC = ({ {expanded && ( - - {args.todos.map((todo, index) => ( - - {getStatusIcon(todo.status)} - - {todo.status === "in_progress" ? ( - {todo.activeForm} - ) : ( - {todo.content} - )} - - - ))} - + )} From 964309826994254993a22466b4376389615cf99a Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:27:20 -0500 Subject: [PATCH 13/22] =?UTF-8?q?=F0=9F=A4=96=20Add=20TODO=20state=20track?= =?UTF-8?q?ing=20to=20StreamingMessageAggregator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track current TODOs in Aggregator when todo_write succeeds - Clear TODOs when stream ends, aborts, or errors - Add getCurrentTodos() getter for efficient access - Single source of truth for TODO state - Automatic cleanup on stream completion --- .../messages/StreamingMessageAggregator.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 280917430..5929d5614 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -46,6 +46,9 @@ export class StreamingMessageAggregator { // Delta history for token counting and TPS calculation private deltaHistory = new Map(); + // Current TODO list (updated when todo_write succeeds) + private currentTodos: import("@/types/tools").TodoItem[] = []; + private invalidateCache(): void { this.cachedAllMessages = null; this.cachedDisplayedMessages = null; @@ -69,6 +72,14 @@ export class StreamingMessageAggregator { return this.recencyTimestamp; } + /** + * Get the current TODO list. + * Updated whenever todo_write succeeds. + */ + getCurrentTodos(): import("@/types/tools").TodoItem[] { + return this.currentTodos; + } + addMessage(message: CmuxMessage): void { // Just store the message - backend assigns historySequence this.messages.set(message.id, message); @@ -221,6 +232,9 @@ export class StreamingMessageAggregator { } handleStreamEnd(data: StreamEndEvent): void { + // Clear TODOs when stream ends + this.currentTodos = []; + // Direct lookup by messageId - O(1) instead of O(n) find const activeStream = this.activeStreams.get(data.messageId); @@ -282,6 +296,9 @@ export class StreamingMessageAggregator { } handleStreamAbort(data: StreamAbortEvent): void { + // Clear TODOs when stream aborts + this.currentTodos = []; + // Direct lookup by messageId const activeStream = this.activeStreams.get(data.messageId); @@ -303,6 +320,9 @@ export class StreamingMessageAggregator { } handleStreamError(data: StreamErrorMessage): void { + // Clear TODOs when stream errors + this.currentTodos = []; + // Direct lookup by messageId const activeStream = this.activeStreams.get(data.messageId); @@ -380,6 +400,18 @@ export class StreamingMessageAggregator { // Type assertion needed because TypeScript can't narrow the discriminated union (toolPart as DynamicToolPartAvailable).state = "output-available"; (toolPart as DynamicToolPartAvailable).output = data.result; + + // Update TODO state if this was a successful todo_write + if ( + data.toolName === "todo_write" && + typeof data.result === "object" && + data.result !== null && + "success" in data.result && + data.result.success + ) { + const args = toolPart.input as { todos: import("@/types/tools").TodoItem[] }; + this.currentTodos = args.todos; + } } this.invalidateCache(); } From b986afe143e168dfc9a5a1574a0fab0b240c5bca Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:28:04 -0500 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=A4=96=20Add=20getTodos()=20method?= =?UTF-8?q?=20to=20WorkspaceStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simple passthrough to Aggregator.getCurrentTodos() - Returns empty array if workspace doesn't exist - Efficient O(1) lookup by workspace ID - No caching needed since Aggregator manages state --- src/stores/WorkspaceStore.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index e5c94c600..76d38f617 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -251,6 +251,15 @@ export class WorkspaceStore { return this.getOrCreateAggregator(workspaceId); } + /** + * Get current TODO list for a workspace. + * Returns empty array if workspace doesn't exist or has no TODOs. + */ + getTodos(workspaceId: string): import("@/types/tools").TodoItem[] { + const aggregator = this.aggregators.get(workspaceId); + return aggregator ? aggregator.getCurrentTodos() : []; + } + /** * Add a workspace and subscribe to its IPC events. */ From 3ab33131949f7fa587184b6301c784cd6797fcee Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:29:40 -0500 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=A4=96=20Create=20PinnedTodoList=20?= =?UTF-8?q?component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuses TodoList component for rendering - Subscribes to WorkspaceStore for reactivity - Shows only when TODOs exist - Styled with border and max-height - Ready to be positioned in chat --- src/components/PinnedTodoList.tsx | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/components/PinnedTodoList.tsx diff --git a/src/components/PinnedTodoList.tsx b/src/components/PinnedTodoList.tsx new file mode 100644 index 000000000..49767670d --- /dev/null +++ b/src/components/PinnedTodoList.tsx @@ -0,0 +1,46 @@ +import React, { useSyncExternalStore } from "react"; +import styled from "@emotion/styled"; +import { TodoList } from "./TodoList"; +import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore"; + +const PinnedContainer = styled.div` + background: var(--color-panel-background); + border-top: 1px solid var(--color-border); + margin: 0; + max-height: 300px; + overflow-y: auto; +`; + +interface PinnedTodoListProps { + workspaceId: string; +} + +/** + * Pinned TODO list displayed at bottom of chat (before StreamingBarrier). + * Shows current TODOs from active stream only. + * Reuses TodoList component for consistent styling. + */ +export const PinnedTodoList: React.FC = ({ workspaceId }) => { + const workspaceStore = useWorkspaceStoreRaw(); + + // Subscribe to workspace state changes to re-render when TODOs update + const workspaceState = useSyncExternalStore( + (callback) => workspaceStore.subscribeKey(workspaceId, callback), + () => workspaceStore.getWorkspaceState(workspaceId) + ); + + // Get current TODOs (uses latest aggregator state) + const todos = workspaceStore.getTodos(workspaceId); + + // Don't render if no TODOs + if (todos.length === 0) { + return null; + } + + return ( + + + + ); +}; + From acf3382db165265c91c36f8d47a1a40085e9bc9a Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:30:52 -0500 Subject: [PATCH 16/22] =?UTF-8?q?=F0=9F=A4=96=20Position=20PinnedTodoList?= =?UTF-8?q?=20before=20StreamingBarrier=20in=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added to AIView.tsx right before StreamingBarrier - Shows at bottom of message list - Only renders when TODOs exist for current workspace - Automatically updates when TODOs change via WorkspaceStore subscription --- src/components/AIView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 27c2dce17..b5ee1956d 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer"; import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; +import { PinnedTodoList } from "./PinnedTodoList"; import { getAutoRetryKey } from "@/constants/storage"; import { ChatInput, type ChatInputAPI } from "./ChatInput"; import { ChatMetaSidebar } from "./ChatMetaSidebar"; @@ -472,6 +473,7 @@ const AIViewInner: React.FC = ({ )} )} + {canInterrupt && ( Date: Tue, 14 Oct 2025 19:44:00 -0500 Subject: [PATCH 17/22] =?UTF-8?q?=F0=9F=A4=96=20Fix=20lint=20errors=20and?= =?UTF-8?q?=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused workspaceState variable in PinnedTodoList - Add proper TodoItem type imports instead of inline import() - Use TodoItem[] for simple array types (not Array) - Fix formatting with prettier --- src/components/PinnedTodoList.tsx | 3 +-- src/components/TodoList.tsx | 1 - src/stores/GitStatusStore.test.ts | 2 +- src/stores/WorkspaceStore.ts | 3 ++- src/utils/messages/StreamingMessageAggregator.ts | 7 ++++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/PinnedTodoList.tsx b/src/components/PinnedTodoList.tsx index 49767670d..2cbef8012 100644 --- a/src/components/PinnedTodoList.tsx +++ b/src/components/PinnedTodoList.tsx @@ -24,7 +24,7 @@ export const PinnedTodoList: React.FC = ({ workspaceId }) = const workspaceStore = useWorkspaceStoreRaw(); // Subscribe to workspace state changes to re-render when TODOs update - const workspaceState = useSyncExternalStore( + useSyncExternalStore( (callback) => workspaceStore.subscribeKey(workspaceId, callback), () => workspaceStore.getWorkspaceState(workspaceId) ); @@ -43,4 +43,3 @@ export const PinnedTodoList: React.FC = ({ workspaceId }) = ); }; - diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 5d572d013..546c17b56 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -131,4 +131,3 @@ export const TodoList: React.FC = ({ todos }) => { ); }; - diff --git a/src/stores/GitStatusStore.test.ts b/src/stores/GitStatusStore.test.ts index 54d57b7af..b0dcc3264 100644 --- a/src/stores/GitStatusStore.test.ts +++ b/src/stores/GitStatusStore.test.ts @@ -48,7 +48,7 @@ describe("GitStatusStore", () => { afterEach(() => { store.dispose(); // Cleanup mocked window to avoid leaking between tests - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (globalThis as { window?: unknown }).window; }); diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 7950b1e26..d5cb86245 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -2,6 +2,7 @@ import type { CmuxMessage, DisplayedMessage } from "@/types/message"; import { createCmuxMessage } from "@/types/message"; import type { WorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage } from "@/types/ipc"; +import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; import { updatePersistedState } from "@/hooks/usePersistedState"; import { getRetryStateKey } from "@/constants/storage"; @@ -256,7 +257,7 @@ export class WorkspaceStore { * Get current TODO list for a workspace. * Returns empty array if workspace doesn't exist or has no TODOs. */ - getTodos(workspaceId: string): import("@/types/tools").TodoItem[] { + getTodos(workspaceId: string): TodoItem[] { const aggregator = this.aggregators.get(workspaceId); return aggregator ? aggregator.getCurrentTodos() : []; } diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 0fe7b7f17..d3751cfd0 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -11,6 +11,7 @@ import type { ReasoningDeltaEvent, ReasoningEndEvent, } from "@/types/stream"; +import type { TodoItem } from "@/types/tools"; import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/types/ipc"; import type { @@ -47,7 +48,7 @@ export class StreamingMessageAggregator { private deltaHistory = new Map(); // Current TODO list (updated when todo_write succeeds) - private currentTodos: import("@/types/tools").TodoItem[] = []; + private currentTodos: TodoItem[] = []; private invalidateCache(): void { this.cachedAllMessages = null; @@ -76,7 +77,7 @@ export class StreamingMessageAggregator { * Get the current TODO list. * Updated whenever todo_write succeeds. */ - getCurrentTodos(): import("@/types/tools").TodoItem[] { + getCurrentTodos(): TodoItem[] { return this.currentTodos; } @@ -404,7 +405,7 @@ export class StreamingMessageAggregator { "success" in data.result && data.result.success ) { - const args = toolPart.input as { todos: import("@/types/tools").TodoItem[] }; + const args = toolPart.input as { todos: TodoItem[] }; this.currentTodos = args.todos; } } From 70a63d59b492751fac6729704672f144eaf49d88 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:47:25 -0500 Subject: [PATCH 18/22] =?UTF-8?q?=F0=9F=A4=96=20Refactor:=20use=20shared?= =?UTF-8?q?=20cleanupStreamState()=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of duplicating 'this.currentTodos = []' and 'this.activeStreams.delete()' in handleStreamEnd, handleStreamAbort, and handleStreamError, extract to a single private method. Benefits: - DRY: Single source of truth for stream cleanup - Maintainability: Adding new stream-scoped state only requires updating one place - Clarity: Explicit method name documents intent --- .../messages/StreamingMessageAggregator.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index d3751cfd0..00ebfeb18 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -81,6 +81,15 @@ export class StreamingMessageAggregator { return this.currentTodos; } + /** + * Clean up stream-scoped state when stream ends (normally or abnormally). + * Called by handleStreamEnd, handleStreamAbort, and handleStreamError. + */ + private cleanupStreamState(messageId: string): void { + this.currentTodos = []; + this.activeStreams.delete(messageId); + } + addMessage(message: CmuxMessage): void { // Just store the message - backend assigns historySequence this.messages.set(message.id, message); @@ -228,9 +237,6 @@ export class StreamingMessageAggregator { } handleStreamEnd(data: StreamEndEvent): void { - // Clear TODOs when stream ends - this.currentTodos = []; - // Direct lookup by messageId - O(1) instead of O(n) find const activeStream = this.activeStreams.get(data.messageId); @@ -267,8 +273,8 @@ export class StreamingMessageAggregator { } } - // Clean up active stream - direct delete by messageId - this.activeStreams.delete(data.messageId); + // Clean up stream-scoped state (TODOs, active stream tracking) + this.cleanupStreamState(data.messageId); } else { // Reconnection case: user reconnected after stream completed // We reconstruct the entire message from the stream-end event @@ -292,9 +298,6 @@ export class StreamingMessageAggregator { } handleStreamAbort(data: StreamAbortEvent): void { - // Clear TODOs when stream aborts - this.currentTodos = []; - // Direct lookup by messageId const activeStream = this.activeStreams.get(data.messageId); @@ -309,16 +312,13 @@ export class StreamingMessageAggregator { }; } - // Clean up active stream - direct delete by messageId - this.activeStreams.delete(data.messageId); + // Clean up stream-scoped state (TODOs, active stream tracking) + this.cleanupStreamState(data.messageId); this.invalidateCache(); } } handleStreamError(data: StreamErrorMessage): void { - // Clear TODOs when stream errors - this.currentTodos = []; - // Direct lookup by messageId const activeStream = this.activeStreams.get(data.messageId); @@ -331,8 +331,8 @@ export class StreamingMessageAggregator { message.metadata.errorType = data.errorType; } - // Clean up active stream - direct delete by messageId - this.activeStreams.delete(data.messageId); + // Clean up stream-scoped state (TODOs, active stream tracking) + this.cleanupStreamState(data.messageId); this.invalidateCache(); } } From 46505ed6e26b772b04e8269c59874007b27c87a5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:49:55 -0500 Subject: [PATCH 19/22] =?UTF-8?q?=F0=9F=A4=96=20Add=20'TODO:'=20header=20t?= =?UTF-8?q?o=20pinned=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Small, subtle header above the pinned TODO list - Uses monospace font at 10px with secondary text color - Letterspacing for visual clarity - Helps identify the component in the chat view --- src/components/PinnedTodoList.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/PinnedTodoList.tsx b/src/components/PinnedTodoList.tsx index 2cbef8012..bd2d5e1a7 100644 --- a/src/components/PinnedTodoList.tsx +++ b/src/components/PinnedTodoList.tsx @@ -11,6 +11,15 @@ const PinnedContainer = styled.div` overflow-y: auto; `; +const TodoHeader = styled.div` + padding: 4px 8px 2px 8px; + font-family: var(--font-monospace); + font-size: 10px; + color: var(--color-text-secondary); + font-weight: 600; + letter-spacing: 0.05em; +`; + interface PinnedTodoListProps { workspaceId: string; } @@ -39,6 +48,7 @@ export const PinnedTodoList: React.FC = ({ workspaceId }) = return ( + TODO: ); From 51a0a1f64cb19f78af61286e07fcaba2121b9fd3 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:50:55 -0500 Subject: [PATCH 20/22] =?UTF-8?q?=F0=9F=A4=96=20Change=20pinned=20TODO=20b?= =?UTF-8?q?order=20to=20dashed=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use 1px dashed border with hsl(0deg 0% 28.64%) for a more subtle, distinguished look that separates the TODO list from the chat. --- src/components/PinnedTodoList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PinnedTodoList.tsx b/src/components/PinnedTodoList.tsx index bd2d5e1a7..064f0ce91 100644 --- a/src/components/PinnedTodoList.tsx +++ b/src/components/PinnedTodoList.tsx @@ -5,7 +5,7 @@ import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore"; const PinnedContainer = styled.div` background: var(--color-panel-background); - border-top: 1px solid var(--color-border); + border-top: 1px dashed hsl(0deg 0% 28.64%); margin: 0; max-height: 300px; overflow-y: auto; From d80299e29408c74a232cdb667104ef59ae8f6ef5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:56:09 -0500 Subject: [PATCH 21/22] =?UTF-8?q?=F0=9F=A4=96=20Remove=20activeForm=20fiel?= =?UTF-8?q?d=20and=20make=20pinned=20TODO=20collapsible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema changes: - Removed activeForm field from TodoItem type - Updated tool description to use tense-based content: - Past tense for completed ('Added tests') - Present progressive for in_progress ('Adding tests') - Imperative for pending ('Add tests') - Saves tokens by using single field with context-appropriate tense UI improvements: - Made pinned TODO list collapsible with animated caret - Colon disappears when collapsed ('TODO:' → 'TODO') - Collapse state persisted globally with usePersistedState - Blue color and ellipsis animation for in_progress items - Updated TodoList component to handle all statuses with single field Tests: - Removed all activeForm references from tests - All 11 tests still pass --- src/components/PinnedTodoList.tsx | 25 ++++++++++- src/components/TodoList.tsx | 67 ++++++++++++++++-------------- src/services/tools/todo.test.ts | 27 +----------- src/types/tools.ts | 1 - src/utils/tools/toolDefinitions.ts | 14 ++++--- 5 files changed, 69 insertions(+), 65 deletions(-) diff --git a/src/components/PinnedTodoList.tsx b/src/components/PinnedTodoList.tsx index 064f0ce91..9c91dca51 100644 --- a/src/components/PinnedTodoList.tsx +++ b/src/components/PinnedTodoList.tsx @@ -2,6 +2,7 @@ import React, { useSyncExternalStore } from "react"; import styled from "@emotion/styled"; import { TodoList } from "./TodoList"; import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore"; +import { usePersistedState } from "@/hooks/usePersistedState"; const PinnedContainer = styled.div` background: var(--color-panel-background); @@ -18,6 +19,22 @@ const TodoHeader = styled.div` color: var(--color-text-secondary); font-weight: 600; letter-spacing: 0.05em; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 4px; + + &:hover { + opacity: 0.8; + } +`; + +const Caret = styled.span<{ expanded: boolean }>` + display: inline-block; + transition: transform 0.2s; + transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")}; + font-size: 8px; `; interface PinnedTodoListProps { @@ -31,6 +48,7 @@ interface PinnedTodoListProps { */ export const PinnedTodoList: React.FC = ({ workspaceId }) => { const workspaceStore = useWorkspaceStoreRaw(); + const [expanded, setExpanded] = usePersistedState("pinnedTodoExpanded", true); // Subscribe to workspace state changes to re-render when TODOs update useSyncExternalStore( @@ -48,8 +66,11 @@ export const PinnedTodoList: React.FC = ({ workspaceId }) = return ( - TODO: - + setExpanded(!expanded)}> + + TODO{expanded ? ":" : ""} + + {expanded && } ); }; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 546c17b56..3e3a9eaa3 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -57,39 +57,46 @@ const TodoContent = styled.div` `; const TodoText = styled.div<{ status: TodoItem["status"] }>` - color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")}; + color: ${(props) => { + switch (props.status) { + case "completed": + return "#888"; + case "in_progress": + return "#2196f3"; + default: + return "var(--color-text)"; + } + }}; text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")}; -`; - -const TodoActiveForm = styled.div` - color: #2196f3; - font-weight: 500; - font-size: 11px; - opacity: 0.95; + font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")}; white-space: nowrap; - &::after { - content: "..."; - display: inline; - overflow: hidden; - animation: ellipsis 1.5s steps(4, end) infinite; - } - - @keyframes ellipsis { - 0% { - content: ""; - } - 25% { - content: "."; - } - 50% { - content: ".."; - } - 75% { + ${(props) => + props.status === "in_progress" && + ` + &::after { content: "..."; + display: inline; + overflow: hidden; + animation: ellipsis 1.5s steps(4, end) infinite; } - } + + @keyframes ellipsis { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + } + `} `; interface TodoListProps { @@ -120,11 +127,7 @@ export const TodoList: React.FC = ({ todos }) => { {getStatusIcon(todo.status)} - {todo.status === "in_progress" ? ( - {todo.activeForm} - ) : ( - {todo.content} - )} + {todo.content} ))} diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts index aa7b6f7df..848bda3a3 100644 --- a/src/services/tools/todo.test.ts +++ b/src/services/tools/todo.test.ts @@ -22,19 +22,16 @@ describe("Todo Storage", () => { it("should store todo list in temp directory", async () => { const todos: TodoItem[] = [ { - content: "Install dependencies", + content: "Installed dependencies", status: "completed", - activeForm: "Installing dependencies", }, { - content: "Write tests", + content: "Writing tests", status: "in_progress", - activeForm: "Writing tests", }, { content: "Update documentation", status: "pending", - activeForm: "Updating documentation", }, ]; @@ -50,12 +47,10 @@ describe("Todo Storage", () => { { content: "Task 1", status: "pending", - activeForm: "Doing task 1", }, { content: "Task 2", status: "pending", - activeForm: "Doing task 2", }, ]; @@ -66,17 +61,14 @@ describe("Todo Storage", () => { { content: "Task 1", status: "completed", - activeForm: "Doing task 1", }, { content: "Task 2", status: "in_progress", - activeForm: "Doing task 2", }, { content: "Task 3", status: "pending", - activeForm: "Doing task 3", }, ]; @@ -93,7 +85,6 @@ describe("Todo Storage", () => { { content: "Task 1", status: "pending", - activeForm: "Doing task 1", }, ]); @@ -109,7 +100,6 @@ describe("Todo Storage", () => { { content: "Step 1", status: "pending", - activeForm: "Handling step 1", }, ]; @@ -119,12 +109,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "in_progress", - activeForm: "Handling step 1", }, { content: "Step 2", status: "in_progress", - activeForm: "Handling step 2", }, ]; @@ -141,12 +129,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "pending", - activeForm: "Handling step 1", }, { content: "Step 2", status: "in_progress", - activeForm: "Handling step 2", }, ]; @@ -160,12 +146,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "in_progress", - activeForm: "Handling step 1", }, { content: "Step 2", status: "completed", - activeForm: "Handling step 2", }, ]; @@ -179,12 +163,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "completed", - activeForm: "Handling step 1", }, { content: "Step 2", status: "completed", - activeForm: "Handling step 2", }, ]; @@ -204,12 +186,10 @@ describe("Todo Storage", () => { { content: "Task 1", status: "completed", - activeForm: "Doing task 1", }, { content: "Task 2", status: "in_progress", - activeForm: "Doing task 2", }, ]; @@ -231,7 +211,6 @@ describe("Todo Storage", () => { { content: "Stream 1 task", status: "pending", - activeForm: "Working on stream 1", }, ]; @@ -239,7 +218,6 @@ describe("Todo Storage", () => { { content: "Stream 2 task", status: "pending", - activeForm: "Working on stream 2", }, ]; @@ -266,7 +244,6 @@ describe("Todo Storage", () => { { content: "Task 1", status: "pending", - activeForm: "Doing task 1", }, ]; diff --git a/src/types/tools.ts b/src/types/tools.ts index df069ee2f..0173acb4b 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -141,7 +141,6 @@ export interface ProposePlanToolResult { export interface TodoItem { content: string; status: "pending" | "in_progress" | "completed"; - activeForm: string; } export interface TodoWriteToolArgs { diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 875a6a849..571ab2dfe 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -160,15 +160,19 @@ export const TOOL_DEFINITIONS = { "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 the stream, ensure all todos are marked as completed.", + "Before finishing the stream, 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'), " + + "and imperative/infinitive for pending (e.g., 'Add tests').", schema: z.object({ todos: z.array( z.object({ - content: z.string().describe("Task description"), - status: z.enum(["pending", "in_progress", "completed"]).describe("Task status"), - activeForm: z + content: z .string() - .describe("Present progressive form of the task (e.g., 'Adding tests')"), + .describe( + "Task description with tense matching status: past for completed, present progressive for in_progress, imperative for pending" + ), + status: z.enum(["pending", "in_progress", "completed"]).describe("Task status"), }) ), }), From 03c13eba1a62ca747f1321c4923472a9343e3d8e Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 19:58:54 -0500 Subject: [PATCH 22/22] =?UTF-8?q?=F0=9F=A4=96=20Improve=20tool=20descripti?= =?UTF-8?q?on=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed 'before finishing the stream' to 'before finishing your response' for better clarity from the AI's perspective. --- src/utils/tools/toolDefinitions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 571ab2dfe..14b33a2c2 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -160,7 +160,7 @@ export const TOOL_DEFINITIONS = { "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 the stream, ensure all todos are marked as completed. " + + "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'), " + "and imperative/infinitive for pending (e.g., 'Add tests').",