Skip to content

Commit e84bc7f

Browse files
refactor(todo): switch to content-keyed incremental updates with cancelled status (#2606)
1 parent c1c0506 commit e84bc7f

15 files changed

+438
-198
lines changed

crates/forge_app/src/fmt/snapshots/forge_app__fmt__todo_fmt__tests__todo_write_mixed_changes_snapshot.snap

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,5 @@ expression: actual
44
---
55

66
󰄵 Task 1
7-
󰄱 Task 2
7+
󰄗 Task 2
88
󰄱 Task 3
9-
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/forge_app/src/fmt/todo_fmt.rs
3+
expression: raw
4+
---
5+
6+
󰄗 Write migrations
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/forge_app/src/fmt/todo_fmt.rs
3+
expression: raw
4+
---
5+
6+
󰄱 Pending task

crates/forge_app/src/fmt/todo_fmt.rs

Lines changed: 135 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,22 @@ fn format_todo_line(todo: &Todo, line_style: TodoLineStyle) -> String {
2121
TodoStatus::Completed => "󰄵",
2222
TodoStatus::InProgress => "󰄗",
2323
TodoStatus::Pending => "󰄱",
24+
TodoStatus::Cancelled => "󰅙",
2425
};
2526

2627
let content = match todo.status {
27-
TodoStatus::Completed => style(todo.content.as_str()).strikethrough().to_string(),
28+
TodoStatus::Completed | TodoStatus::Cancelled => {
29+
style(todo.content.as_str()).strikethrough().to_string()
30+
}
2831
_ => todo.content.clone(),
2932
};
3033

3134
let line = format!(" {checkbox} {content}");
3235
let styled = match (&todo.status, line_style) {
3336
(TodoStatus::Pending, TodoLineStyle::Bold) => style(line).white().bold().to_string(),
3437
(TodoStatus::Pending, TodoLineStyle::Dim) => style(line).white().dim().to_string(),
38+
(TodoStatus::Cancelled, TodoLineStyle::Bold) => style(line).red().bold().to_string(),
39+
(TodoStatus::Cancelled, TodoLineStyle::Dim) => style(line).red().dim().to_string(),
3540
(TodoStatus::InProgress, TodoLineStyle::Bold) => style(line).cyan().bold().to_string(),
3641
(TodoStatus::InProgress, TodoLineStyle::Dim) => style(line).cyan().dim().to_string(),
3742
(TodoStatus::Completed, TodoLineStyle::Bold) => style(line).green().bold().to_string(),
@@ -53,72 +58,56 @@ pub(crate) fn format_todos_diff(before: &[Todo], after: &[Todo]) -> String {
5358

5459
let before_map: std::collections::HashMap<&str, &Todo> =
5560
before.iter().map(|todo| (todo.id.as_str(), todo)).collect();
56-
let after_ids: std::collections::HashSet<&str> =
57-
after.iter().map(|todo| todo.id.as_str()).collect();
61+
let after_map: std::collections::HashMap<&str, &Todo> =
62+
after.iter().map(|todo| (todo.id.as_str(), todo)).collect();
5863

5964
let mut result = "\n".to_string();
6065

61-
enum DiffLine<'a> {
62-
Current {
63-
todo: &'a Todo,
64-
line_style: TodoLineStyle,
65-
},
66-
Removed {
67-
todo: &'a Todo,
68-
},
69-
}
70-
71-
impl DiffLine<'_> {
72-
fn id(&self) -> &str {
73-
match self {
74-
DiffLine::Current { todo, .. } | DiffLine::Removed { todo } => todo.id.as_str(),
66+
// Walk `before` in insertion order: emit the current version of surviving
67+
// items, or the removed rendering for items that were dropped.
68+
for before_todo in before {
69+
if let Some(after_todo) = after_map.get(before_todo.id.as_str()).copied() {
70+
// Item still exists — render with bold/dim based on whether it changed.
71+
let is_changed = before_todo.status != after_todo.status
72+
|| before_todo.content != after_todo.content;
73+
let line_style = if is_changed {
74+
TodoLineStyle::Bold
75+
} else {
76+
TodoLineStyle::Dim
77+
};
78+
result.push_str(&format_todo_line(after_todo, line_style));
79+
} else {
80+
// Item was removed — render with status-aware styling.
81+
let content = style(before_todo.content.as_str())
82+
.strikethrough()
83+
.to_string();
84+
if before_todo.status == TodoStatus::Completed {
85+
// Removed completed: dimmed white checkmark (historical done)
86+
result.push_str(&format!(
87+
" {}\n",
88+
style(format!("󰄵 {content}")).white().dim()
89+
));
90+
} else {
91+
// Removed non-completed: use the correct status icon in red
92+
let checkbox = match before_todo.status {
93+
TodoStatus::InProgress => "󰄗",
94+
TodoStatus::Pending => "󰄱",
95+
TodoStatus::Cancelled => "󰅙",
96+
TodoStatus::Completed => "󰄵",
97+
};
98+
result.push_str(&format!(
99+
" {}\n",
100+
style(format!("{checkbox} {content}")).red()
101+
));
75102
}
76103
}
77104
}
78105

79-
let mut lines: Vec<DiffLine<'_>> = Vec::new();
80-
106+
// Append newly-added items (present in `after` but not in `before`) in
107+
// their original insertion order.
81108
for todo in after {
82-
let previous = before_map.get(todo.id.as_str()).copied();
83-
let is_new = previous.is_none();
84-
let is_changed = previous
85-
.map(|item| item.status != todo.status || item.content != todo.content)
86-
.unwrap_or(false);
87-
88-
let line_style = if is_new || is_changed {
89-
TodoLineStyle::Bold
90-
} else {
91-
TodoLineStyle::Dim
92-
};
93-
94-
lines.push(DiffLine::Current { todo, line_style });
95-
}
96-
97-
for todo in before {
98-
if !after_ids.contains(todo.id.as_str()) {
99-
lines.push(DiffLine::Removed { todo });
100-
}
101-
}
102-
103-
lines.sort_by(|left, right| left.id().cmp(right.id()));
104-
105-
for line in lines {
106-
match line {
107-
DiffLine::Current { todo, line_style } => {
108-
result.push_str(&format_todo_line(todo, line_style));
109-
}
110-
DiffLine::Removed { todo } => {
111-
let content = style(todo.content.as_str()).strikethrough().to_string();
112-
113-
if todo.status == TodoStatus::Completed {
114-
result.push_str(&format!(
115-
" {}\n",
116-
style(format!("󰄵 {content}")).white().dim()
117-
));
118-
} else {
119-
result.push_str(&format!(" {}\n", style(format!("󰄱 {content}")).red()));
120-
}
121-
}
109+
if !before_map.contains_key(todo.id.as_str()) {
110+
result.push_str(&format_todo_line(todo, TodoLineStyle::Bold));
122111
}
123112
}
124113

@@ -137,10 +126,7 @@ pub(crate) fn format_todos(todos: &[Todo]) -> String {
137126

138127
let mut result = "\n".to_string();
139128

140-
let mut sorted_todos: Vec<&Todo> = todos.iter().collect();
141-
sorted_todos.sort_by(|left, right| left.id.cmp(&right.id));
142-
143-
for todo in sorted_todos {
129+
for todo in todos {
144130
result.push_str(&format_todo_line(todo, TodoLineStyle::Dim));
145131
}
146132

@@ -149,14 +135,43 @@ pub(crate) fn format_todos(todos: &[Todo]) -> String {
149135

150136
#[cfg(test)]
151137
mod tests {
152-
use console::strip_ansi_codes;
138+
use std::sync::Mutex;
139+
140+
use console::{
141+
colors_enabled, colors_enabled_stderr, set_colors_enabled, set_colors_enabled_stderr,
142+
strip_ansi_codes,
143+
};
153144
use forge_domain::{ChatResponseContent, Environment, Todo, TodoStatus};
154145
use insta::assert_snapshot;
155146
use pretty_assertions::assert_eq;
156147

157148
use crate::fmt::content::FormatContent;
158149
use crate::operation::ToolOperation;
159150

151+
static ANSI_STYLE_LOCK: Mutex<()> = Mutex::new(());
152+
153+
struct ColorStateGuard {
154+
stdout: bool,
155+
stderr: bool,
156+
}
157+
158+
impl ColorStateGuard {
159+
fn force_enabled() -> Self {
160+
let stdout = colors_enabled();
161+
let stderr = colors_enabled_stderr();
162+
set_colors_enabled(true);
163+
set_colors_enabled_stderr(true);
164+
Self { stdout, stderr }
165+
}
166+
}
167+
168+
impl Drop for ColorStateGuard {
169+
fn drop(&mut self) {
170+
set_colors_enabled(self.stdout);
171+
set_colors_enabled_stderr(self.stderr);
172+
}
173+
}
174+
160175
fn fixture_environment() -> Environment {
161176
use fake::{Fake, Faker};
162177

@@ -178,6 +193,58 @@ mod tests {
178193
Todo::new(content).id(id).status(status)
179194
}
180195

196+
fn fixture_todo_write_output_raw(before: Vec<Todo>, after: Vec<Todo>) -> String {
197+
let _lock = ANSI_STYLE_LOCK
198+
.lock()
199+
.expect("ANSI style lock should not be poisoned");
200+
let _colors = ColorStateGuard::force_enabled();
201+
let setup = ToolOperation::TodoWrite { before, after };
202+
let actual = setup.to_content(&fixture_environment());
203+
204+
if let Some(ChatResponseContent::ToolOutput(output)) = actual {
205+
output
206+
} else {
207+
panic!("Expected ToolOutput content")
208+
}
209+
}
210+
211+
#[test]
212+
fn test_todo_write_removed_in_progress_renders_with_in_progress_icon_in_raw_snapshot() {
213+
// before: Write migrations is in_progress
214+
// after: empty (it was cancelled/removed)
215+
let setup = (
216+
vec![fixture_todo(
217+
"Write migrations",
218+
"1",
219+
TodoStatus::InProgress,
220+
)],
221+
Vec::new(),
222+
);
223+
224+
// Verify icon (strip color) — must be 󰄗, NOT 󰄱
225+
let plain = fixture_todo_write_output(setup.0.clone(), setup.1.clone());
226+
let expected_plain = "\n 󰄗 Write migrations\n";
227+
assert_eq!(plain, expected_plain);
228+
229+
let raw = fixture_todo_write_output_raw(setup.0, setup.1);
230+
assert_snapshot!(raw);
231+
}
232+
233+
#[test]
234+
fn test_todo_write_removed_pending_renders_with_pending_icon_in_raw_snapshot() {
235+
let setup = (
236+
vec![fixture_todo("Pending task", "1", TodoStatus::Pending)],
237+
Vec::new(),
238+
);
239+
240+
let plain = fixture_todo_write_output(setup.0.clone(), setup.1.clone());
241+
let expected_plain = "\n 󰄱 Pending task\n";
242+
assert_eq!(plain, expected_plain);
243+
244+
let raw = fixture_todo_write_output_raw(setup.0, setup.1);
245+
assert_snapshot!(raw);
246+
}
247+
181248
fn fixture_todo_write_output(before: Vec<Todo>, after: Vec<Todo>) -> String {
182249
let setup = ToolOperation::TodoWrite { before, after };
183250
let actual = setup.to_content(&fixture_environment());
@@ -220,14 +287,16 @@ mod tests {
220287
}
221288

222289
#[test]
223-
fn test_format_todos_are_sorted_by_id() {
290+
fn test_format_todos_preserves_insertion_order() {
291+
// Items are given in insertion order: Second was added first, First second.
292+
// Output must reflect that insertion order, not alphabetical or id-sorted.
224293
let setup = vec![
225294
fixture_todo("Second", "2", TodoStatus::Pending),
226295
fixture_todo("First", "1", TodoStatus::Pending),
227296
];
228297

229298
let actual = strip_ansi_codes(super::format_todos(&setup).as_str()).to_string();
230-
let expected = "\n 󰄱 First\n 󰄱 Second\n";
299+
let expected = "\n 󰄱 Second\n 󰄱 First\n";
231300

232301
assert_eq!(actual, expected);
233302
}

crates/forge_app/src/operation.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,6 @@ impl ToolOperation {
655655

656656
for todo in added {
657657
let todo_elm = Element::new("todo")
658-
.attr("id", &todo.id)
659658
.attr("status", todo.status.to_string())
660659
.attr("change", "added")
661660
.text(&todo.content);
@@ -664,7 +663,6 @@ impl ToolOperation {
664663

665664
for (prev, todo) in updated {
666665
let mut todo_elm = Element::new("todo")
667-
.attr("id", &todo.id)
668666
.attr("status", todo.status.to_string())
669667
.attr("change", "updated");
670668
if prev.status != todo.status {
@@ -678,7 +676,6 @@ impl ToolOperation {
678676

679677
for todo in removed {
680678
let todo_elm = Element::new("todo")
681-
.attr("id", &todo.id)
682679
.attr("status", todo.status.to_string())
683680
.attr("change", "removed")
684681
.text(&todo.content);
@@ -692,7 +689,6 @@ impl ToolOperation {
692689

693690
for todo in output {
694691
let todo_elm = Element::new("todo")
695-
.attr("id", &todo.id)
696692
.attr("status", todo.status.to_string())
697693
.text(&todo.content);
698694
elm = elm.append(todo_elm);

crates/forge_app/src/snapshots/forge_app__tool_registry__all_rendered_tool_descriptions.snap

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,22 @@ Fetches detailed information about a specific skill. Use this tool to load skill
184184
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
185185
It also helps the user understand the progress of the task and overall progress of their requests.
186186

187+
## How It Works
188+
189+
Each call sends only the items that changedyou do not need to repeat the whole list.
190+
191+
Each item has two required fields:
192+
- `content`: The task description. This is the **unique key** — the server matches on content to decide whether to add or update.
193+
- `status`: One of `pending`, `in_progress`, `completed`, or `cancelled`.
194+
195+
**Rules:**
196+
- Item with this `content` does **not** exist yet → **added** as a new task.
197+
- Item with this `content` already existsits `status` is **updated**.
198+
- `status: cancelled` → the item is **removed** from the list entirely.
199+
- Items you do not mention are **left unchanged**.
200+
201+
IDs are managed internally by the system and are never exposed to you.
202+
187203
## When to Use This Tool
188204
Use this tool proactively in these scenarios:
189205

@@ -329,26 +345,24 @@ The assistant did not use the todo list because this is a single command executi
329345
## Task States and Management
330346

331347
1. **Task States**: Use these states to track progress:
332-
- pending: Task not yet started
333-
- in_progress: Currently working on (limit to ONE task at a time)
334-
- completed: Task finished successfully
335-
336-
**IMPORTANT**: Task descriptions must have two forms:
337-
- content: The imperative form describing what needs to be done (e.g., "Run tests", "Build the project")
338-
- activeForm: The present continuous form shown during execution (e.g., "Running tests", "Building the project")
348+
- `pending`: Task not yet started
349+
- `in_progress`: Currently working on (limit to ONE task at a time)
350+
- `completed`: Task finished successfully
351+
- `cancelled`: Task is no longer relevant — this removes it from the list
339352

340353
2. **Task Management**:
341-
- Update task status in real-time as you work
342-
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
343-
- Exactly ONE task must be in_progress at any time (not less, not more)
354+
- Only send the items that changed — do not repeat unchanged items
355+
- Mark tasks `in_progress` BEFORE beginning work
356+
- Mark tasks `completed` IMMEDIATELY after finishing (don't batch completions)
357+
- Exactly ONE task must be `in_progress` at any time
358+
- Use `cancelled` to remove tasks that are no longer relevant
344359
- Complete current tasks before starting new ones
345-
- Remove tasks that are no longer relevant from the list entirely
346360

347361
3. **Task Completion Requirements**:
348-
- ONLY mark a task as completed when you have FULLY accomplished it
349-
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
362+
- ONLY mark a task as `completed` when you have FULLY accomplished it
363+
- If you encounter errors, blockers, or cannot finish, keep the task as `in_progress`
350364
- When blocked, create a new task describing what needs to be resolved
351-
- Never mark a task as completed if:
365+
- Never mark a task as `completed` if:
352366
- Tests are failing
353367
- Implementation is partial
354368
- You encountered unresolved errors
@@ -358,9 +372,6 @@ The assistant did not use the todo list because this is a single command executi
358372
- Create specific, actionable items
359373
- Break complex tasks into smaller, manageable steps
360374
- Use clear, descriptive task names
361-
- Always provide both forms:
362-
- content: "Fix authentication bug"
363-
- activeForm: "Fixing authentication bug"
364375

365376
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
366377

0 commit comments

Comments
 (0)