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): one row per task, windowed around the in-progress
6//! task when the list outgrows the cap, with a dim overflow footer
7//! ("… +4 pending, 2 completed"). Collapsed (Ctrl+T): a single "Next:" row
8//! naming the upcoming pending task (the ACTIVE task already lives on the
9//! 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};
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
30/// Maximum task rows shown expanded (excluding the overflow footer).
31const MAX_ROWS: usize = 8;
32
33/// Whether the checklist band renders at all: there must be something to
34/// show, and a fully-green list retires once the run goes idle (finished
35/// work stays visible only while unfinished work remains). Collapsed with no
36/// status widget above (`attached` false) renders nothing — the state
37/// persists and the one-liner reappears when the next run starts.
38#[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/// Build the checklist rows for the reserved zone. `collapsed` is the Ctrl+T
55/// one-line form. `attached` means the status zone renders above, so the
56/// first row carries the `⎿` connector; detached rows sit flush-left.
57/// `width` is the zone's inner width in cells.
58#[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        // Ellipsis aligns under the subject column in either mode.
97        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/// Height the layout should reserve for the checklist zone.
107#[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        // Windowed rows + overflow footer.
117        (MAX_ROWS as u16) + 1
118    }
119}
120
121/// Pick the rows to show: everything when it fits; otherwise start at the
122/// first non-completed task (completed rows scroll away first, matching the
123/// screenshots) and summarize the rest in the footer.
124fn 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    // Keep the window from overshooting the tail: back it up so MAX_ROWS
133    // always fill when enough tasks exist.
134    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
152/// One checklist row. Attached, the first row carries the `⎿` gutter that
153/// visually connects the list to the spinner line above and the rest indent
154/// to align; detached there is nothing to hang from, so rows sit flush-left.
155fn 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    // Completed rows earn a dim cost suffix when stamps exist: "(2m 10s · 8.4k tok)".
173    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) // glyph + space
186        .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        // ⊘ (U+2298, Mathematical Operators) stays outside the banned emoji
205        // ranges like the other glyphs.
206        ChecklistStatus::Blocked => {
207            spans.push(Span::styled("⊘ ", warning));
208            spans.push(Span::styled(subject, text));
209        },
210        // Deleted never reaches here (filtered by `visible()`), but the
211        // match stays exhaustive per house rule.
212        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
226/// The collapsed one-liner: what's up next (the active task already shows on
227/// the spinner line). All done → the plain progress count.
228fn 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
251/// `" (2m 10s · 8.4k tok)"` for a completed task, empty when unstamped.
252fn 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        // Collapsed with no status widget above renders nothing at all…
349        assert!(!tasks_visible(&store, &TurnState::Idle, true, false));
350        // …but stays visible while attached (spinner or agent rows above).
351        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        // 8 windowed rows + footer.
391        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        // A blocked task hidden past the window shows up in the footer.
412        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        // A single completed task while a run is still busy stays visible.
474        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}