Skip to main content

mermaid_cli/render/widgets/
tasks.rs

1//! The live task checklist, rendered directly under the status/spinner line
2//! (Claude Code visual parity — the spinner row reads as the checklist
3//! header, these rows hang beneath it behind a `⎿` gutter).
4//!
5//! Expanded (default): wrapped rows per task with inline Markdown support,
6//! windowed around the in-progress task when the list outgrows the visual line
7//! cap, with a dim overflow footer ("… +4 pending, 2 completed"). Collapsed
8//! (Ctrl+T): a single "Next:" row naming the upcoming pending task (the ACTIVE
9//! task already lives on the spinner line above).
10//!
11//! The `⎿` gutter is subordinate to the status widget: it only renders when
12//! the status zone above is actually showing (`attached`). Detached (idle,
13//! no agent rows), expanded rows sit flush-left with no elbow, and the
14//! collapsed one-liner disappears entirely — collapse is a minimize-while-
15//! working affordance with nothing to minimize into when idle.
16//!
17//! Glyphs are deliberately outside the no-emoji CI ranges: `√` completed
18//! (U+221A — the dingbat checkmarks U+2713/14 are banned), `■` in-progress,
19//! `□` pending (geometric shapes), `⎿` gutter (house transcript glyph).
20
21use 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
33/// Maximum visual lines shown expanded (including task rows and overflow footer).
34const MAX_ROWS: usize = 8;
35
36/// Whether the checklist band renders at all: there must be something to
37/// show, and a fully-green list retires once the run goes idle (finished
38/// work stays visible only while unfinished work remains). Collapsed with no
39/// status widget above (`attached` false) renders nothing — the state
40/// persists and the one-liner reappears when the next run starts.
41#[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/// Build the checklist rows for the reserved zone. `collapsed` is the Ctrl+T
58/// one-line form. `attached` means the status zone renders above, so the
59/// first row carries the `⎿` connector; detached rows sit flush-left.
60/// `width` is the zone's inner width in cells.
61#[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        // Ellipsis aligns under the subject column in either mode.
99        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/// Height the layout should reserve for the checklist zone.
109#[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
123/// Window tasks so total visual lines fit within [`MAX_ROWS`], centering on the
124/// in-progress task and summarizing hidden items.
125fn 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
182/// One checklist row wrapped across lines with inline Markdown and hanging indent.
183fn 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
241/// The collapsed one-liner: what's up next with inline Markdown parsing.
242fn 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
265/// `" (2m 10s · 8.4k tok)"` for a completed task, empty when unstamped.
266fn 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        // Collapsed with no status widget above renders nothing at all…
364        assert!(!tasks_visible(&store, &TurnState::Idle, true, false));
365        // …but stays visible while attached (spinner or agent rows above).
366        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        // 8 windowed rows + footer.
407        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        // A blocked task hidden past the window shows up in the footer.
428        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        // Width 40 will force wrapping
505        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        // Continuation line should have hanging indent (5 spaces: 3 gutter + 2 glyph)
517        assert!(
518            rows[1].starts_with("     "),
519            "continuation indent missing: {:?}",
520            rows[1]
521        );
522        // Check that bold and code styling is present
523        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}