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::domain::tasks::{TaskItem, TaskStatus, TaskStore};
25use crate::domain::{TaskOrigin, TurnState};
26use crate::render::theme::Theme;
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.
38pub fn tasks_visible(store: &TaskStore, turn: &TurnState, collapsed: bool, attached: bool) -> bool {
39    if store.is_empty() {
40        return false;
41    }
42    if collapsed && !attached {
43        return false;
44    }
45    !(matches!(turn, TurnState::Idle) && store.all_done())
46}
47
48/// Build the checklist rows for the reserved zone. `collapsed` is the Ctrl+T
49/// one-line form. `attached` means the status zone renders above, so the
50/// first row carries the `⎿` connector; detached rows sit flush-left.
51/// `width` is the zone's inner width in cells.
52pub fn build_task_lines(
53    store: &TaskStore,
54    collapsed: bool,
55    attached: bool,
56    width: u16,
57    theme: &Theme,
58) -> Vec<Line<'static>> {
59    if width < 10 {
60        return Vec::new();
61    }
62    let width = width as usize;
63    let meta_style = Style::new()
64        .fg(theme.colors.text_secondary.to_color())
65        .dim();
66
67    if collapsed {
68        return vec![collapsed_line(store, width, theme, meta_style)];
69    }
70
71    let visible: Vec<&TaskItem> = store.visible().collect();
72    let (window, hidden_completed, hidden_pending, hidden_blocked) = window_rows(&visible);
73
74    let mut lines = Vec::with_capacity(window.len() + 1);
75    for (i, task) in window.iter().enumerate() {
76        lines.push(task_row(task, i == 0, attached, width, theme, meta_style));
77    }
78    if hidden_completed + hidden_pending + hidden_blocked > 0 {
79        let mut bits = Vec::new();
80        if hidden_pending > 0 {
81            bits.push(format!("+{hidden_pending} pending"));
82        }
83        if hidden_blocked > 0 {
84            bits.push(format!("+{hidden_blocked} blocked"));
85        }
86        if hidden_completed > 0 {
87            bits.push(format!("{hidden_completed} completed"));
88        }
89        // Ellipsis aligns under the subject column in either mode.
90        let footer_pad = if attached { "    " } else { "  " };
91        lines.push(Line::from(Span::styled(
92            truncate_to_cells(&format!("{footer_pad}… {}", bits.join(", ")), width),
93            meta_style,
94        )));
95    }
96    lines
97}
98
99/// Height the layout should reserve for the checklist zone.
100pub fn tasks_height(store: &TaskStore, collapsed: bool) -> u16 {
101    if collapsed {
102        return 1;
103    }
104    let visible = store.visible().count();
105    if visible <= MAX_ROWS {
106        visible as u16
107    } else {
108        // Windowed rows + overflow footer.
109        (MAX_ROWS as u16) + 1
110    }
111}
112
113/// Pick the rows to show: everything when it fits; otherwise start at the
114/// first non-completed task (completed rows scroll away first, matching the
115/// screenshots) and summarize the rest in the footer.
116fn window_rows<'a>(visible: &[&'a TaskItem]) -> (Vec<&'a TaskItem>, usize, usize, usize) {
117    if visible.len() <= MAX_ROWS {
118        return (visible.to_vec(), 0, 0, 0);
119    }
120    let start = visible
121        .iter()
122        .position(|t| t.status != TaskStatus::Completed)
123        .unwrap_or(0);
124    // Keep the window from overshooting the tail: back it up so MAX_ROWS
125    // always fill when enough tasks exist.
126    let start = start.min(visible.len() - MAX_ROWS);
127    let window: Vec<&TaskItem> = visible[start..start + MAX_ROWS].to_vec();
128    let hidden = |slice: &[&TaskItem], status: TaskStatus| {
129        slice.iter().filter(|t| t.status == status).count()
130    };
131    let before = &visible[..start];
132    let after = &visible[start + MAX_ROWS..];
133    (
134        window,
135        hidden(before, TaskStatus::Completed) + hidden(after, TaskStatus::Completed),
136        hidden(before, TaskStatus::Pending)
137            + hidden(after, TaskStatus::Pending)
138            + hidden(before, TaskStatus::InProgress)
139            + hidden(after, TaskStatus::InProgress),
140        hidden(before, TaskStatus::Blocked) + hidden(after, TaskStatus::Blocked),
141    )
142}
143
144/// One checklist row. Attached, the first row carries the `⎿` gutter that
145/// visually connects the list to the spinner line above and the rest indent
146/// to align; detached there is nothing to hang from, so rows sit flush-left.
147fn task_row(
148    task: &TaskItem,
149    first: bool,
150    attached: bool,
151    width: usize,
152    theme: &Theme,
153    meta_style: Style,
154) -> Line<'static> {
155    let gutter = match (attached, first) {
156        (true, true) => " ⎿ ",
157        (true, false) => "   ",
158        (false, _) => "",
159    };
160    let brand = Style::new().fg(theme.colors.brand.to_color());
161    let warning = Style::new().fg(theme.colors.warning.to_color());
162    let text = Style::new().fg(theme.colors.text_primary.to_color());
163
164    // Completed rows earn a dim cost suffix when stamps exist: "(2m 10s · 8.4k tok)".
165    let suffix = if task.status == TaskStatus::Completed {
166        cost_suffix(task)
167    } else {
168        String::new()
169    };
170    let user_marker = if task.origin == TaskOrigin::User {
171        " (you)"
172    } else {
173        ""
174    };
175
176    let budget = width
177        .saturating_sub(gutter.len() + 2) // glyph + space
178        .saturating_sub(suffix.len())
179        .saturating_sub(user_marker.len());
180    let subject = truncate_to_cells(&task.subject, budget.max(4));
181
182    let mut spans = vec![Span::styled(gutter.to_string(), meta_style)];
183    match task.status {
184        TaskStatus::Completed => {
185            spans.push(Span::styled("√ ", brand));
186            spans.push(Span::styled(subject, meta_style.crossed_out()));
187        },
188        TaskStatus::InProgress => {
189            spans.push(Span::styled("■ ", warning));
190            spans.push(Span::styled(subject, brand.bold()));
191        },
192        TaskStatus::Pending => {
193            spans.push(Span::styled("□ ", meta_style));
194            spans.push(Span::styled(subject, text));
195        },
196        // ⊘ (U+2298, Mathematical Operators) stays outside the banned emoji
197        // ranges like the other glyphs.
198        TaskStatus::Blocked => {
199            spans.push(Span::styled("⊘ ", warning));
200            spans.push(Span::styled(subject, text));
201        },
202        // Deleted never reaches here (filtered by `visible()`), but the
203        // match stays exhaustive per house rule.
204        TaskStatus::Deleted => {
205            spans.push(Span::styled("x ", meta_style));
206            spans.push(Span::styled(subject, meta_style));
207        },
208    }
209    if !user_marker.is_empty() {
210        spans.push(Span::styled(user_marker.to_string(), meta_style));
211    }
212    if !suffix.is_empty() {
213        spans.push(Span::styled(suffix, meta_style));
214    }
215    Line::from(spans)
216}
217
218/// The collapsed one-liner: what's up next (the active task already shows on
219/// the spinner line). All done → the plain progress count.
220fn collapsed_line(
221    store: &TaskStore,
222    width: usize,
223    theme: &Theme,
224    meta_style: Style,
225) -> Line<'static> {
226    let text_style = Style::new().fg(theme.colors.text_primary.to_color());
227    match store.next_pending() {
228        Some(next) => {
229            let head = " ⎿ Next: ";
230            let budget = (width).saturating_sub(head.len()).max(4);
231            Line::from(vec![
232                Span::styled(head.to_string(), meta_style),
233                Span::styled(truncate_to_cells(&next.subject, budget), text_style),
234            ])
235        },
236        None => Line::from(Span::styled(
237            format!(" ⎿ {}", store.progress_string()),
238            meta_style,
239        )),
240    }
241}
242
243/// `" (2m 10s · 8.4k tok)"` for a completed task, empty when unstamped.
244fn cost_suffix(task: &TaskItem) -> String {
245    let mut bits = Vec::new();
246    if let Some(secs) = task.elapsed_secs()
247        && secs > 0
248    {
249        bits.push(format_duration(secs));
250    }
251    if let Some(tokens) = task.tokens_spent
252        && tokens > 0
253    {
254        bits.push(format!(
255            "{} tok",
256            crate::domain::format_compact_count(tokens as usize)
257        ));
258    }
259    if bits.is_empty() {
260        String::new()
261    } else {
262        format!(" ({})", bits.join(" · "))
263    }
264}
265
266fn format_duration(secs: u64) -> String {
267    if secs >= 3600 {
268        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
269    } else if secs >= 60 {
270        format!("{}m {}s", secs / 60, secs % 60)
271    } else {
272        format!("{secs}s")
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::domain::TaskEdit;
280    use crate::domain::tasks::{Stamp, TaskSpec};
281
282    fn store_of(statuses: &[TaskStatus]) -> TaskStore {
283        let mut store = TaskStore::default();
284        store.create(
285            statuses
286                .iter()
287                .enumerate()
288                .map(|(i, _)| TaskSpec {
289                    subject: format!("task number {i}"),
290                    active_form: format!("doing task {i}"),
291                    description: None,
292                    in_progress: false,
293                })
294                .collect(),
295            TaskOrigin::Model,
296            Stamp::default(),
297        );
298        let edits: Vec<TaskEdit> = statuses
299            .iter()
300            .enumerate()
301            .filter(|(_, s)| **s != TaskStatus::Pending)
302            .map(|(i, s)| TaskEdit {
303                id: (i + 1) as u32,
304                status: Some(*s),
305                ..TaskEdit::default()
306            })
307            .collect();
308        store.apply(&edits, Stamp::default());
309        store
310    }
311
312    fn rendered(lines: &[Line<'_>]) -> Vec<String> {
313        lines
314            .iter()
315            .map(|l| {
316                l.spans
317                    .iter()
318                    .map(|s| s.content.as_ref())
319                    .collect::<String>()
320            })
321            .collect()
322    }
323
324    #[test]
325    fn visibility_rules() {
326        use TaskStatus::*;
327        let store = store_of(&[Completed, InProgress, Pending]);
328        assert!(tasks_visible(&store, &TurnState::Idle, false, false));
329        let done = store_of(&[Completed, Completed]);
330        assert!(
331            !tasks_visible(&done, &TurnState::Idle, false, true),
332            "all-done idle retires"
333        );
334        assert!(!tasks_visible(
335            &TaskStore::default(),
336            &TurnState::Idle,
337            false,
338            true
339        ));
340        // Collapsed with no status widget above renders nothing at all…
341        assert!(!tasks_visible(&store, &TurnState::Idle, true, false));
342        // …but stays visible while attached (spinner or agent rows above).
343        assert!(tasks_visible(&store, &TurnState::Idle, true, true));
344    }
345
346    #[test]
347    fn expanded_rows_carry_glyphs_and_gutter() {
348        use TaskStatus::*;
349        let store = store_of(&[Completed, InProgress, Pending]);
350        let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
351        let rows = rendered(&lines);
352        assert_eq!(rows.len(), 3);
353        assert!(rows[0].starts_with(" ⎿ √ "), "{:?}", rows[0]);
354        assert!(rows[1].starts_with("   ■ "), "{:?}", rows[1]);
355        assert!(rows[2].starts_with("   □ "), "{:?}", rows[2]);
356    }
357
358    #[test]
359    fn detached_rows_drop_elbow_and_sit_flush() {
360        use TaskStatus::*;
361        let store = store_of(&[Completed, InProgress, Pending]);
362        let lines = build_task_lines(&store, false, false, 80, &Theme::dark());
363        let rows = rendered(&lines);
364        assert_eq!(rows.len(), 3);
365        assert!(rows[0].starts_with("√ "), "{:?}", rows[0]);
366        assert!(rows[1].starts_with("■ "), "{:?}", rows[1]);
367        assert!(rows[2].starts_with("□ "), "{:?}", rows[2]);
368        assert!(!rows.iter().any(|r| r.contains('⎿')), "{rows:?}");
369    }
370
371    #[test]
372    fn long_lists_window_and_summarize() {
373        use TaskStatus::*;
374        let statuses: Vec<TaskStatus> = [Completed, Completed]
375            .into_iter()
376            .chain([InProgress])
377            .chain(std::iter::repeat_n(Pending, 9))
378            .collect();
379        let store = store_of(&statuses);
380        let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
381        let rows = rendered(&lines);
382        // 8 windowed rows + footer.
383        assert_eq!(rows.len(), 9);
384        assert!(
385            rows[0].contains("■"),
386            "window starts at in_progress: {:?}",
387            rows[0]
388        );
389        let footer = rows.last().unwrap();
390        assert!(footer.contains("+2 pending"), "{footer:?}");
391        assert!(footer.contains("2 completed"), "{footer:?}");
392        assert_eq!(tasks_height(&store, false), 9);
393    }
394
395    #[test]
396    fn blocked_rows_render_glyph_and_footer_counts_them() {
397        use TaskStatus::*;
398        let store = store_of(&[Blocked, InProgress, Pending]);
399        let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
400        let rows = rendered(&lines);
401        assert!(rows[0].starts_with(" ⎿ ⊘ "), "{:?}", rows[0]);
402
403        // A blocked task hidden past the window shows up in the footer.
404        let statuses: Vec<TaskStatus> = [InProgress]
405            .into_iter()
406            .chain(std::iter::repeat_n(Pending, 7))
407            .chain([Blocked])
408            .chain(std::iter::repeat_n(Pending, 2))
409            .collect();
410        let store = store_of(&statuses);
411        let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
412        let footer = rendered(&lines).last().unwrap().clone();
413        assert!(footer.contains("+1 blocked"), "{footer:?}");
414        assert!(footer.contains("+2 pending"), "{footer:?}");
415    }
416
417    #[test]
418    fn collapsed_shows_next_pending() {
419        use TaskStatus::*;
420        let store = store_of(&[Completed, InProgress, Pending]);
421        let lines = build_task_lines(&store, true, true, 80, &Theme::dark());
422        let rows = rendered(&lines);
423        assert_eq!(rows.len(), 1);
424        assert!(rows[0].contains("Next: task number 2"), "{:?}", rows[0]);
425        assert_eq!(tasks_height(&store, true), 1);
426
427        let no_pending = store_of(&[Completed, InProgress]);
428        let rows = rendered(&build_task_lines(
429            &no_pending,
430            true,
431            true,
432            80,
433            &Theme::dark(),
434        ));
435        assert!(rows[0].contains("Tasks 1/2"), "{:?}", rows[0]);
436    }
437
438    #[test]
439    fn completed_rows_show_cost_and_user_marker() {
440        let mut store = TaskStore::default();
441        store.create(
442            vec![TaskSpec {
443                subject: "review the docs".into(),
444                active_form: "reviewing the docs".into(),
445                description: None,
446                in_progress: true,
447            }],
448            TaskOrigin::User,
449            Stamp {
450                now_epoch: 100,
451                run_tokens: 1_000,
452            },
453        );
454        store.apply(
455            &[TaskEdit {
456                id: 1,
457                status: Some(TaskStatus::Completed),
458                ..TaskEdit::default()
459            }],
460            Stamp {
461                now_epoch: 230,
462                run_tokens: 9_400,
463            },
464        );
465        // A single completed task while a run is still busy stays visible.
466        let lines = build_task_lines(&store, false, true, 100, &Theme::dark());
467        let row = &rendered(&lines)[0];
468        assert!(row.contains("(2m 10s · 8.4k tok)"), "{row:?}");
469        assert!(row.contains("(you)"), "{row:?}");
470    }
471}