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