1use ratatui::style::Style;
22use ratatui::text::{Line, Span};
23
24use crate::render::theme::Theme;
25use mermaid_domain::checklist::{ChecklistItem, ChecklistStatus, ChecklistStore};
26use mermaid_domain::{ChecklistOrigin, TurnState};
27
28use super::truncate_to_cells;
29
30const MAX_ROWS: usize = 8;
32
33#[must_use]
39pub fn tasks_visible(
40 store: &ChecklistStore,
41 turn: &TurnState,
42 collapsed: bool,
43 attached: bool,
44) -> bool {
45 if store.is_empty() {
46 return false;
47 }
48 if collapsed && !attached {
49 return false;
50 }
51 !(matches!(turn, TurnState::Idle) && store.all_done())
52}
53
54#[must_use]
59pub fn build_task_lines(
60 store: &ChecklistStore,
61 collapsed: bool,
62 attached: bool,
63 width: u16,
64 theme: &Theme,
65) -> Vec<Line<'static>> {
66 if width < 10 {
67 return Vec::new();
68 }
69 let width = width as usize;
70 let meta_style = Style::new()
71 .fg(theme.colors.text_secondary.to_color())
72 .dim();
73
74 if collapsed {
75 return vec![collapsed_line(store, width, theme, meta_style)];
76 }
77
78 let visible: Vec<&ChecklistItem> = store.visible().collect();
79 let (window, hidden_completed, hidden_pending, hidden_blocked) = window_rows(&visible);
80
81 let mut lines = Vec::with_capacity(window.len() + 1);
82 for (i, task) in window.iter().enumerate() {
83 lines.push(task_row(task, i == 0, attached, width, theme, meta_style));
84 }
85 if hidden_completed + hidden_pending + hidden_blocked > 0 {
86 let mut bits = Vec::new();
87 if hidden_pending > 0 {
88 bits.push(format!("+{hidden_pending} pending"));
89 }
90 if hidden_blocked > 0 {
91 bits.push(format!("+{hidden_blocked} blocked"));
92 }
93 if hidden_completed > 0 {
94 bits.push(format!("{hidden_completed} completed"));
95 }
96 let footer_pad = if attached { " " } else { " " };
98 lines.push(Line::from(Span::styled(
99 truncate_to_cells(&format!("{footer_pad}… {}", bits.join(", ")), width),
100 meta_style,
101 )));
102 }
103 lines
104}
105
106#[must_use]
108pub fn tasks_height(store: &ChecklistStore, collapsed: bool) -> u16 {
109 if collapsed {
110 return 1;
111 }
112 let visible = store.visible().count();
113 if visible <= MAX_ROWS {
114 visible as u16
115 } else {
116 (MAX_ROWS as u16) + 1
118 }
119}
120
121fn window_rows<'a>(visible: &[&'a ChecklistItem]) -> (Vec<&'a ChecklistItem>, usize, usize, usize) {
125 if visible.len() <= MAX_ROWS {
126 return (visible.to_vec(), 0, 0, 0);
127 }
128 let start = visible
129 .iter()
130 .position(|t| t.status != ChecklistStatus::Completed)
131 .unwrap_or(0);
132 let start = start.min(visible.len() - MAX_ROWS);
135 let window: Vec<&ChecklistItem> = visible[start..start + MAX_ROWS].to_vec();
136 let hidden = |slice: &[&ChecklistItem], status: ChecklistStatus| {
137 slice.iter().filter(|t| t.status == status).count()
138 };
139 let before = &visible[..start];
140 let after = &visible[start + MAX_ROWS..];
141 (
142 window,
143 hidden(before, ChecklistStatus::Completed) + hidden(after, ChecklistStatus::Completed),
144 hidden(before, ChecklistStatus::Pending)
145 + hidden(after, ChecklistStatus::Pending)
146 + hidden(before, ChecklistStatus::InProgress)
147 + hidden(after, ChecklistStatus::InProgress),
148 hidden(before, ChecklistStatus::Blocked) + hidden(after, ChecklistStatus::Blocked),
149 )
150}
151
152fn task_row(
156 task: &ChecklistItem,
157 first: bool,
158 attached: bool,
159 width: usize,
160 theme: &Theme,
161 meta_style: Style,
162) -> Line<'static> {
163 let gutter = match (attached, first) {
164 (true, true) => " ⎿ ",
165 (true, false) => " ",
166 (false, _) => "",
167 };
168 let brand = Style::new().fg(theme.colors.brand.to_color());
169 let warning = Style::new().fg(theme.colors.warning.to_color());
170 let text = Style::new().fg(theme.colors.text_primary.to_color());
171
172 let suffix = if task.status == ChecklistStatus::Completed {
174 cost_suffix(task)
175 } else {
176 String::new()
177 };
178 let user_marker = if task.origin == ChecklistOrigin::User {
179 " (you)"
180 } else {
181 ""
182 };
183
184 let budget = width
185 .saturating_sub(gutter.len() + 2) .saturating_sub(suffix.len())
187 .saturating_sub(user_marker.len());
188 let subject = truncate_to_cells(&task.subject, budget.max(4));
189
190 let mut spans = vec![Span::styled(gutter.to_string(), meta_style)];
191 match task.status {
192 ChecklistStatus::Completed => {
193 spans.push(Span::styled("√ ", brand));
194 spans.push(Span::styled(subject, meta_style.crossed_out()));
195 },
196 ChecklistStatus::InProgress => {
197 spans.push(Span::styled("■ ", warning));
198 spans.push(Span::styled(subject, brand.bold()));
199 },
200 ChecklistStatus::Pending => {
201 spans.push(Span::styled("□ ", meta_style));
202 spans.push(Span::styled(subject, text));
203 },
204 ChecklistStatus::Blocked => {
207 spans.push(Span::styled("⊘ ", warning));
208 spans.push(Span::styled(subject, text));
209 },
210 ChecklistStatus::Deleted => {
213 spans.push(Span::styled("x ", meta_style));
214 spans.push(Span::styled(subject, meta_style));
215 },
216 }
217 if !user_marker.is_empty() {
218 spans.push(Span::styled(user_marker.to_string(), meta_style));
219 }
220 if !suffix.is_empty() {
221 spans.push(Span::styled(suffix, meta_style));
222 }
223 Line::from(spans)
224}
225
226fn collapsed_line(
229 store: &ChecklistStore,
230 width: usize,
231 theme: &Theme,
232 meta_style: Style,
233) -> Line<'static> {
234 let text_style = Style::new().fg(theme.colors.text_primary.to_color());
235 match store.next_pending() {
236 Some(next) => {
237 let head = " ⎿ Next: ";
238 let budget = (width).saturating_sub(head.len()).max(4);
239 Line::from(vec![
240 Span::styled(head.to_string(), meta_style),
241 Span::styled(truncate_to_cells(&next.subject, budget), text_style),
242 ])
243 },
244 None => Line::from(Span::styled(
245 format!(" ⎿ {}", store.progress_string()),
246 meta_style,
247 )),
248 }
249}
250
251fn cost_suffix(task: &ChecklistItem) -> String {
253 let mut bits = Vec::new();
254 if let Some(secs) = task.elapsed_secs()
255 && secs > 0
256 {
257 bits.push(format_duration(secs));
258 }
259 if let Some(tokens) = task.tokens_spent
260 && tokens > 0
261 {
262 bits.push(format!(
263 "{} tok",
264 mermaid_domain::format_compact_count(tokens as usize)
265 ));
266 }
267 if bits.is_empty() {
268 String::new()
269 } else {
270 format!(" ({})", bits.join(" · "))
271 }
272}
273
274fn format_duration(secs: u64) -> String {
275 if secs >= 3600 {
276 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
277 } else if secs >= 60 {
278 format!("{}m {}s", secs / 60, secs % 60)
279 } else {
280 format!("{secs}s")
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use mermaid_domain::ChecklistEdit;
288 use mermaid_domain::checklist::{ChecklistSpec, Stamp};
289
290 fn store_of(statuses: &[ChecklistStatus]) -> ChecklistStore {
291 let mut store = ChecklistStore::default();
292 store.create(
293 statuses
294 .iter()
295 .enumerate()
296 .map(|(i, _)| ChecklistSpec {
297 subject: format!("task number {i}"),
298 active_form: format!("doing task {i}"),
299 description: None,
300 in_progress: false,
301 })
302 .collect(),
303 ChecklistOrigin::Model,
304 Stamp::default(),
305 );
306 let edits: Vec<ChecklistEdit> = statuses
307 .iter()
308 .enumerate()
309 .filter(|(_, s)| **s != ChecklistStatus::Pending)
310 .map(|(i, s)| ChecklistEdit {
311 id: (i + 1) as u32,
312 status: Some(*s),
313 ..ChecklistEdit::default()
314 })
315 .collect();
316 store.apply(&edits, Stamp::default());
317 store
318 }
319
320 fn rendered(lines: &[Line<'_>]) -> Vec<String> {
321 lines
322 .iter()
323 .map(|l| {
324 l.spans
325 .iter()
326 .map(|s| s.content.as_ref())
327 .collect::<String>()
328 })
329 .collect()
330 }
331
332 #[test]
333 fn visibility_rules() {
334 use ChecklistStatus::*;
335 let store = store_of(&[Completed, InProgress, Pending]);
336 assert!(tasks_visible(&store, &TurnState::Idle, false, false));
337 let done = store_of(&[Completed, Completed]);
338 assert!(
339 !tasks_visible(&done, &TurnState::Idle, false, true),
340 "all-done idle retires"
341 );
342 assert!(!tasks_visible(
343 &ChecklistStore::default(),
344 &TurnState::Idle,
345 false,
346 true
347 ));
348 assert!(!tasks_visible(&store, &TurnState::Idle, true, false));
350 assert!(tasks_visible(&store, &TurnState::Idle, true, true));
352 }
353
354 #[test]
355 fn expanded_rows_carry_glyphs_and_gutter() {
356 use ChecklistStatus::*;
357 let store = store_of(&[Completed, InProgress, Pending]);
358 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
359 let rows = rendered(&lines);
360 assert_eq!(rows.len(), 3);
361 assert!(rows[0].starts_with(" ⎿ √ "), "{:?}", rows[0]);
362 assert!(rows[1].starts_with(" ■ "), "{:?}", rows[1]);
363 assert!(rows[2].starts_with(" □ "), "{:?}", rows[2]);
364 }
365
366 #[test]
367 fn detached_rows_drop_elbow_and_sit_flush() {
368 use ChecklistStatus::*;
369 let store = store_of(&[Completed, InProgress, Pending]);
370 let lines = build_task_lines(&store, false, false, 80, &Theme::dark());
371 let rows = rendered(&lines);
372 assert_eq!(rows.len(), 3);
373 assert!(rows[0].starts_with("√ "), "{:?}", rows[0]);
374 assert!(rows[1].starts_with("■ "), "{:?}", rows[1]);
375 assert!(rows[2].starts_with("□ "), "{:?}", rows[2]);
376 assert!(!rows.iter().any(|r| r.contains('⎿')), "{rows:?}");
377 }
378
379 #[test]
380 fn long_lists_window_and_summarize() {
381 use ChecklistStatus::*;
382 let statuses: Vec<ChecklistStatus> = [Completed, Completed]
383 .into_iter()
384 .chain([InProgress])
385 .chain(std::iter::repeat_n(Pending, 9))
386 .collect();
387 let store = store_of(&statuses);
388 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
389 let rows = rendered(&lines);
390 assert_eq!(rows.len(), 9);
392 assert!(
393 rows[0].contains("■"),
394 "window starts at in_progress: {:?}",
395 rows[0]
396 );
397 let footer = rows.last().unwrap();
398 assert!(footer.contains("+2 pending"), "{footer:?}");
399 assert!(footer.contains("2 completed"), "{footer:?}");
400 assert_eq!(tasks_height(&store, false), 9);
401 }
402
403 #[test]
404 fn blocked_rows_render_glyph_and_footer_counts_them() {
405 use ChecklistStatus::*;
406 let store = store_of(&[Blocked, InProgress, Pending]);
407 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
408 let rows = rendered(&lines);
409 assert!(rows[0].starts_with(" ⎿ ⊘ "), "{:?}", rows[0]);
410
411 let statuses: Vec<ChecklistStatus> = [InProgress]
413 .into_iter()
414 .chain(std::iter::repeat_n(Pending, 7))
415 .chain([Blocked])
416 .chain(std::iter::repeat_n(Pending, 2))
417 .collect();
418 let store = store_of(&statuses);
419 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
420 let footer = rendered(&lines).last().unwrap().clone();
421 assert!(footer.contains("+1 blocked"), "{footer:?}");
422 assert!(footer.contains("+2 pending"), "{footer:?}");
423 }
424
425 #[test]
426 fn collapsed_shows_next_pending() {
427 use ChecklistStatus::*;
428 let store = store_of(&[Completed, InProgress, Pending]);
429 let lines = build_task_lines(&store, true, true, 80, &Theme::dark());
430 let rows = rendered(&lines);
431 assert_eq!(rows.len(), 1);
432 assert!(rows[0].contains("Next: task number 2"), "{:?}", rows[0]);
433 assert_eq!(tasks_height(&store, true), 1);
434
435 let no_pending = store_of(&[Completed, InProgress]);
436 let rows = rendered(&build_task_lines(
437 &no_pending,
438 true,
439 true,
440 80,
441 &Theme::dark(),
442 ));
443 assert!(rows[0].contains("Tasks 1/2"), "{:?}", rows[0]);
444 }
445
446 #[test]
447 fn completed_rows_show_cost_and_user_marker() {
448 let mut store = ChecklistStore::default();
449 store.create(
450 vec![ChecklistSpec {
451 subject: "review the docs".into(),
452 active_form: "reviewing the docs".into(),
453 description: None,
454 in_progress: true,
455 }],
456 ChecklistOrigin::User,
457 Stamp {
458 now_epoch: 100,
459 run_tokens: 1_000,
460 },
461 );
462 store.apply(
463 &[ChecklistEdit {
464 id: 1,
465 status: Some(ChecklistStatus::Completed),
466 ..ChecklistEdit::default()
467 }],
468 Stamp {
469 now_epoch: 230,
470 run_tokens: 9_400,
471 },
472 );
473 let lines = build_task_lines(&store, false, true, 100, &Theme::dark());
475 let row = &rendered(&lines)[0];
476 assert!(row.contains("(2m 10s · 8.4k tok)"), "{row:?}");
477 assert!(row.contains("(you)"), "{row:?}");
478 }
479}