Skip to content

Commit 81d1568

Browse files
authored
🤖 Add TODO list feature for tracking multi-step tasks (#241)
Adds TodoWrite and TodoRead tools to enable the AI to manage task lists during complex operations, following Claude Code's pattern with a simplified schema. ## Changes **Tool Implementation:** - TodoWrite: Full-replacement semantics (replaces entire list on each call) - TodoRead: Returns current todo list - Stream-scoped filesystem storage in tmpdir (automatic cleanup when stream ends) - Schema: content, status (pending/in_progress/completed), activeForm fields **UI Component:** - Visual indicators: ✓ (completed), ⏳ (in_progress), ○ (pending) - Shows activeForm text for in-progress items - Color-coded borders and backgrounds by status **Testing:** - Unit tests for storage layer (7 tests) - All existing tests pass (477 total) ## Design Decisions **Simplified schema:** Removed id, priority, timestamps, and metadata fields (unnecessary complexity). Added `activeForm` for better UX ("Adding tests" vs "Add tests"). **Stream-scoped storage:** TODOs stored in stream's tmpdir as `todos.json`. Automatically cleaned up when stream ends. Each stream has isolated storage (no cross-stream pollution). **Full-replacement semantics:** AI replaces entire list on each call. Simpler than incremental updates and matches Claude Code's pattern. ## Benefits ✅ **Automatic cleanup:** tmpdir removed when stream ends, preventing memory leaks ✅ **Stream isolation:** Concurrent streams in same workspace don't share TODOs ✅ **Inspectable:** Can view todos.json during debugging ✅ **Crash resilient:** TODOs persisted to disk during stream _Generated with `cmux`_
1 parent d29d26d commit 81d1568

File tree

8 files changed

+693
-0
lines changed

8 files changed

+693
-0
lines changed

src/components/Messages/ToolMessage.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BashToolCall } from "../tools/BashToolCall";
66
import { FileEditToolCall } from "../tools/FileEditToolCall";
77
import { FileReadToolCall } from "../tools/FileReadToolCall";
88
import { ProposePlanToolCall } from "../tools/ProposePlanToolCall";
9+
import { TodoToolCall } from "../tools/TodoToolCall";
910
import type {
1011
BashToolArgs,
1112
BashToolResult,
@@ -19,6 +20,8 @@ import type {
1920
FileEditReplaceLinesToolResult,
2021
ProposePlanToolArgs,
2122
ProposePlanToolResult,
23+
TodoWriteToolArgs,
24+
TodoWriteToolResult,
2225
} from "@/types/tools";
2326

2427
interface ToolMessageProps {
@@ -65,6 +68,11 @@ function isProposePlanTool(toolName: string, args: unknown): args is ProposePlan
6568
return TOOL_DEFINITIONS.propose_plan.schema.safeParse(args).success;
6669
}
6770

71+
function isTodoWriteTool(toolName: string, args: unknown): args is TodoWriteToolArgs {
72+
if (toolName !== "todo_write") return false;
73+
return TOOL_DEFINITIONS.todo_write.schema.safeParse(args).success;
74+
}
75+
6876
export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, workspaceId }) => {
6977
// Route to specialized components based on tool name
7078
if (isBashTool(message.toolName, message.args)) {
@@ -144,6 +152,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, wo
144152
);
145153
}
146154

155+
if (isTodoWriteTool(message.toolName, message.args)) {
156+
return (
157+
<div className={className}>
158+
<TodoToolCall
159+
args={message.args}
160+
result={message.result as TodoWriteToolResult | undefined}
161+
status={message.status}
162+
/>
163+
</div>
164+
);
165+
}
166+
147167
// Fallback to generic tool call
148168
return (
149169
<div className={className}>
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
import type { TodoWriteToolArgs, TodoWriteToolResult, TodoItem } from "@/types/tools";
4+
import {
5+
ToolContainer,
6+
ToolHeader,
7+
ExpandIcon,
8+
StatusIndicator,
9+
ToolDetails,
10+
} from "./shared/ToolPrimitives";
11+
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
12+
import { TooltipWrapper, Tooltip } from "../Tooltip";
13+
14+
const TodoList = styled.div`
15+
display: flex;
16+
flex-direction: column;
17+
gap: 3px;
18+
padding: 6px 8px;
19+
`;
20+
21+
const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>`
22+
display: flex;
23+
align-items: flex-start;
24+
gap: 6px;
25+
padding: 4px 8px;
26+
background: ${(props) => {
27+
switch (props.status) {
28+
case "completed":
29+
return "color-mix(in srgb, #4caf50, transparent 92%)";
30+
case "in_progress":
31+
return "color-mix(in srgb, #2196f3, transparent 92%)";
32+
case "pending":
33+
default:
34+
return "color-mix(in srgb, #888, transparent 96%)";
35+
}
36+
}};
37+
border-left: 2px solid
38+
${(props) => {
39+
switch (props.status) {
40+
case "completed":
41+
return "#4caf50";
42+
case "in_progress":
43+
return "#2196f3";
44+
case "pending":
45+
default:
46+
return "#666";
47+
}
48+
}};
49+
border-radius: 3px;
50+
font-family: var(--font-monospace);
51+
font-size: 11px;
52+
line-height: 1.35;
53+
color: var(--color-text);
54+
`;
55+
56+
const TodoIcon = styled.div`
57+
font-size: 12px;
58+
flex-shrink: 0;
59+
margin-top: 1px;
60+
opacity: 0.8;
61+
`;
62+
63+
const TodoContent = styled.div`
64+
flex: 1;
65+
min-width: 0;
66+
`;
67+
68+
const TodoText = styled.div<{ status: TodoItem["status"] }>`
69+
color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")};
70+
text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")};
71+
opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")};
72+
`;
73+
74+
const TodoActiveForm = styled.div`
75+
color: #2196f3;
76+
font-weight: 500;
77+
font-size: 11px;
78+
opacity: 0.95;
79+
white-space: nowrap;
80+
81+
&::after {
82+
content: "...";
83+
display: inline;
84+
overflow: hidden;
85+
animation: ellipsis 1.5s steps(4, end) infinite;
86+
}
87+
88+
@keyframes ellipsis {
89+
0% {
90+
content: "";
91+
}
92+
25% {
93+
content: ".";
94+
}
95+
50% {
96+
content: "..";
97+
}
98+
75% {
99+
content: "...";
100+
}
101+
}
102+
`;
103+
104+
interface TodoToolCallProps {
105+
args: TodoWriteToolArgs;
106+
result?: TodoWriteToolResult;
107+
status?: ToolStatus;
108+
}
109+
110+
function getStatusIcon(status: TodoItem["status"]): string {
111+
switch (status) {
112+
case "completed":
113+
return "✓";
114+
case "in_progress":
115+
return "⏳";
116+
case "pending":
117+
default:
118+
return "○";
119+
}
120+
}
121+
122+
export const TodoToolCall: React.FC<TodoToolCallProps> = ({
123+
args,
124+
result: _result,
125+
status = "pending",
126+
}) => {
127+
const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default
128+
const statusDisplay = getStatusDisplay(status);
129+
130+
return (
131+
<ToolContainer expanded={expanded}>
132+
<ToolHeader onClick={toggleExpanded}>
133+
<ExpandIcon expanded={expanded}></ExpandIcon>
134+
<TooltipWrapper inline>
135+
<span>📋</span>
136+
<Tooltip>todo_write</Tooltip>
137+
</TooltipWrapper>
138+
<StatusIndicator status={status}>{statusDisplay}</StatusIndicator>
139+
</ToolHeader>
140+
141+
{expanded && (
142+
<ToolDetails>
143+
<TodoList>
144+
{args.todos.map((todo, index) => (
145+
<TodoItemContainer key={index} status={todo.status}>
146+
<TodoIcon>{getStatusIcon(todo.status)}</TodoIcon>
147+
<TodoContent>
148+
{todo.status === "in_progress" ? (
149+
<TodoActiveForm>{todo.activeForm}</TodoActiveForm>
150+
) : (
151+
<TodoText status={todo.status}>{todo.content}</TodoText>
152+
)}
153+
</TodoContent>
154+
</TodoItemContainer>
155+
))}
156+
</TodoList>
157+
</ToolDetails>
158+
)}
159+
</ToolContainer>
160+
);
161+
};

0 commit comments

Comments
 (0)