@@ -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) ]
151137mod 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 }
0 commit comments