| 1 | //! Checklist and todo transcript rendering helpers. |
| 2 | |
| 3 | use ratatui::style::{Color, Style}; |
| 4 | use ratatui::text::{Line, Span}; |
| 5 | use serde_json::Value; |
| 6 | use unicode_width::UnicodeWidthStr; |
| 7 | |
| 8 | use crate::palette; |
| 9 | |
| 10 | use super::{ |
| 11 | RenderMode, TRANSCRIPT_RAIL, ToolStatus, render_card_detail_line_single, render_compact_kv, |
| 12 | render_tool_header_with_family_and_summary, tool_status_label, tool_value_style, truncate_text, |
| 13 | wrap_text, |
| 14 | }; |
| 15 | |
| 16 | pub(super) fn is_checklist_tool_name(name: &str) -> bool { |
| 17 | matches!( |
| 18 | name, |
| 19 | "work_update" |
| 20 | | "checklist_write" |
| 21 | | "checklist_add" |
| 22 | | "checklist_update" |
| 23 | | "todo_write" |
| 24 | | "todo_add" |
| 25 | | "todo_update" |
| 26 | ) |
| 27 | } |
| 28 | |
| 29 | #[derive(Debug, Clone)] |
| 30 | pub(super) struct ChecklistItemSnapshot { |
| 31 | pub(super) content: String, |
| 32 | pub(super) status: String, |
| 33 | } |
| 34 | |
| 35 | #[derive(Debug, Clone, Default)] |
| 36 | pub(super) struct ChecklistSnapshot { |
| 37 | pub(super) items: Vec<ChecklistItemSnapshot>, |
| 38 | pub(super) completion_pct: u8, |
| 39 | pub(super) completed: usize, |
| 40 | pub(super) total: usize, |
| 41 | } |
| 42 | |
| 43 | /// Pull a structured checklist snapshot out of the tool's text output. |
| 44 | /// The tool emits a leading human-readable line followed by JSON, so we |
| 45 | /// scan for the first `{` and parse from there. Returns `None` if the |
| 46 | /// payload is missing the expected `items` array. |
| 47 | pub(super) fn parse_checklist_snapshot(output: &str) -> Option<ChecklistSnapshot> { |
| 48 | let json_start = output.find('{')?; |
| 49 | let parsed: Value = serde_json::from_str(&output[json_start..]).ok()?; |
| 50 | let items_value = parsed.get("items")?.as_array()?; |
| 51 | |
| 52 | let items: Vec<ChecklistItemSnapshot> = items_value |
| 53 | .iter() |
| 54 | .map(|item| ChecklistItemSnapshot { |
| 55 | content: item |
| 56 | .get("content") |
| 57 | .and_then(Value::as_str) |
| 58 | .unwrap_or("") |
| 59 | .to_string(), |
| 60 | status: item |
| 61 | .get("status") |
| 62 | .and_then(Value::as_str) |
| 63 | .unwrap_or("pending") |
| 64 | .to_string(), |
| 65 | }) |
| 66 | .collect(); |
| 67 | |
| 68 | if items.is_empty() { |
| 69 | return None; |
| 70 | } |
| 71 | |
| 72 | let completed = items |
| 73 | .iter() |
| 74 | .filter(|item| item.status.eq_ignore_ascii_case("completed")) |
| 75 | .count(); |
| 76 | let total = items.len(); |
| 77 | let completion_pct = parsed |
| 78 | .get("completion_pct") |
| 79 | .and_then(Value::as_u64) |
| 80 | .map(|pct| u8::try_from(pct.min(100)).unwrap_or(100)) |
| 81 | .unwrap_or_else(|| { |
| 82 | (completed * 100) |
| 83 | .checked_div(total) |
| 84 | .and_then(|pct| u8::try_from(pct).ok()) |
| 85 | .unwrap_or(0) |
| 86 | }); |
| 87 | |
| 88 | Some(ChecklistSnapshot { |
| 89 | items, |
| 90 | completion_pct, |
| 91 | completed, |
| 92 | total, |
| 93 | }) |
| 94 | } |
| 95 | |
| 96 | /// One parsed "Updated todo #N to STATUS" prefix line emitted by |
| 97 | /// `todo_update` / `checklist_update`. Used by [`render_checklist_change_card`] |
| 98 | /// to show a compact state-change line instead of the full item list. |
| 99 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 100 | pub(super) struct ChecklistChange { |
| 101 | pub(super) id: u32, |
| 102 | pub(super) status: String, |
| 103 | } |
| 104 | |
| 105 | /// Parse the leading line of a checklist-update tool output. Returns |
| 106 | /// `None` for non-update outputs (e.g. `todo_write` snapshots, errors, |
| 107 | /// or an unexpected format) so the caller falls back to the full-list |
| 108 | /// renderer. |
| 109 | pub(super) fn parse_update_prefix(output: &str) -> Option<ChecklistChange> { |
| 110 | // The tool output shape is `Updated todo #3 to in_progress\n{ ... }`. |
| 111 | // We tolerate `checklist` or `todo` as the noun and any reasonable |
| 112 | // status word (the snapshot lookup in the renderer is the source of |
| 113 | // truth for the title — we just need the id+status pair). |
| 114 | let first = output.lines().next()?.trim(); |
| 115 | let rest = first |
| 116 | .strip_prefix("Updated todo #") |
| 117 | .or_else(|| first.strip_prefix("Updated checklist #"))?; |
| 118 | let (id_str, after) = rest.split_once(' ')?; |
| 119 | let id: u32 = id_str.parse().ok()?; |
| 120 | let status = after.strip_prefix("to ")?.trim().to_string(); |
| 121 | if status.is_empty() { |
| 122 | return None; |
| 123 | } |
| 124 | Some(ChecklistChange { id, status }) |
| 125 | } |
| 126 | |
| 127 | /// Render a compact one-line state-change card for `todo_update` / |
| 128 | /// `checklist_update` calls (#403). Shows the changed item's marker, |
| 129 | /// title, and old -> new status, with a `M/N · pct%` progress summary |
| 130 | /// in the header. The full list is still available through the tool |
| 131 | /// detail record. |
| 132 | pub(super) fn render_checklist_change_card( |
| 133 | name: &str, |
| 134 | status: ToolStatus, |
| 135 | snapshot: &ChecklistSnapshot, |
| 136 | change: &ChecklistChange, |
| 137 | width: u16, |
| 138 | low_motion: bool, |
| 139 | ) -> Vec<Line<'static>> { |
| 140 | let mut lines = Vec::new(); |
| 141 | let header_summary = format!( |
| 142 | "{}/{} \u{00B7} {}%", |
| 143 | snapshot.completed, snapshot.total, snapshot.completion_pct |
| 144 | ); |
| 145 | let family = crate::tui::widgets::tool_card::tool_family_for_name(name); |
| 146 | lines.push(render_tool_header_with_family_and_summary( |
| 147 | family, |
| 148 | Some(&header_summary), |
| 149 | tool_status_label(status), |
| 150 | status, |
| 151 | None, |
| 152 | low_motion, |
| 153 | )); |
| 154 | |
| 155 | // Look up the title from the snapshot. `id` in tool input is |
| 156 | // 1-indexed; `items` is 0-indexed. |
| 157 | let item = (change.id as usize) |
| 158 | .checked_sub(1) |
| 159 | .and_then(|idx| snapshot.items.get(idx)); |
| 160 | let title = item |
| 161 | .map(|i| i.content.trim().to_string()) |
| 162 | .filter(|s| !s.is_empty()) |
| 163 | .unwrap_or_else(|| "(missing title)".to_string()); |
| 164 | |
| 165 | let (marker, marker_color) = checklist_status_marker(&change.status); |
| 166 | let prefix = format!("{marker} "); |
| 167 | let prefix_width = |
| 168 | UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str()); |
| 169 | let id_label = format!("Todo #{}", change.id); |
| 170 | let arrow = " \u{2192} "; |
| 171 | let status_label = change.status.clone(); |
| 172 | let title_budget = usize::from(width) |
| 173 | .saturating_sub(prefix_width) |
| 174 | .saturating_sub(UnicodeWidthStr::width(id_label.as_str())) |
| 175 | .saturating_sub(UnicodeWidthStr::width(arrow)) |
| 176 | .saturating_sub(UnicodeWidthStr::width(status_label.as_str())) |
| 177 | .saturating_sub(2) |
| 178 | .max(8); |
| 179 | let title_truncated = truncate_text(title.as_str(), title_budget); |
| 180 | |
| 181 | let spans = vec![ |
| 182 | Span::styled( |
| 183 | "\u{258F} ".to_string(), |
| 184 | Style::default().fg(palette::TEXT_DIM), |
| 185 | ), |
| 186 | Span::styled(prefix, Style::default().fg(marker_color)), |
| 187 | Span::styled(id_label, Style::default().fg(palette::TEXT_DIM)), |
| 188 | Span::styled(": ".to_string(), Style::default().fg(palette::TEXT_DIM)), |
| 189 | Span::styled(title_truncated, tool_value_style()), |
| 190 | Span::styled(arrow.to_string(), Style::default().fg(palette::TEXT_DIM)), |
| 191 | Span::styled(status_label, Style::default().fg(marker_color)), |
| 192 | ]; |
| 193 | lines.push(Line::from(spans)); |
| 194 | |
| 195 | // Tease that the full list is still available without leaving the |
| 196 | // transcript. Mirrors the same affordance used by other tool cells. |
| 197 | lines.push(render_card_detail_line_single( |
| 198 | None, |
| 199 | &format!( |
| 200 | "{} item{}; {}", |
| 201 | snapshot.total, |
| 202 | if snapshot.total == 1 { "" } else { "s" }, |
| 203 | crate::tui::key_shortcuts::tool_details_shortcut_action_hint("list") |
| 204 | ), |
| 205 | Style::default().fg(palette::TEXT_MUTED), |
| 206 | )); |
| 207 | lines |
| 208 | } |
| 209 | |
| 210 | fn checklist_status_marker(status: &str) -> (&'static str, Color) { |
| 211 | match status.to_ascii_lowercase().as_str() { |
| 212 | "completed" | "done" => ("\u{2611}", palette::STATUS_SUCCESS), // ☑ |
| 213 | "in_progress" | "inprogress" | "running" => ("\u{25D0}", palette::WHALE_INFO), // ◐ |
| 214 | "blocked" | "failed" => ("\u{2717}", palette::STATUS_ERROR), // ✗ |
| 215 | "cancelled" | "canceled" | "skipped" => ("\u{2298}", palette::TEXT_MUTED), // ⊘ |
| 216 | _ => ("\u{2610}", palette::TEXT_MUTED), // ☐ pending |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | const CHECKLIST_LIVE_ITEM_LIMIT: usize = 8; |
| 221 | |
| 222 | pub(super) fn render_checklist_card( |
| 223 | name: &str, |
| 224 | status: ToolStatus, |
| 225 | snapshot: &ChecklistSnapshot, |
| 226 | width: u16, |
| 227 | low_motion: bool, |
| 228 | mode: RenderMode, |
| 229 | ) -> Vec<Line<'static>> { |
| 230 | let mut lines = Vec::new(); |
| 231 | let header_summary = format!( |
| 232 | "{}/{} \u{00B7} {}%", |
| 233 | snapshot.completed, snapshot.total, snapshot.completion_pct |
| 234 | ); |
| 235 | let family = crate::tui::widgets::tool_card::tool_family_for_name(name); |
| 236 | lines.push(render_tool_header_with_family_and_summary( |
| 237 | family, |
| 238 | Some(&header_summary), |
| 239 | tool_status_label(status), |
| 240 | status, |
| 241 | None, |
| 242 | low_motion, |
| 243 | )); |
| 244 | lines.extend(render_compact_kv( |
| 245 | "checklist", |
| 246 | name, |
| 247 | tool_value_style(), |
| 248 | width, |
| 249 | )); |
| 250 | |
| 251 | let cap = match mode { |
| 252 | RenderMode::Live => CHECKLIST_LIVE_ITEM_LIMIT, |
| 253 | RenderMode::Transcript => snapshot.items.len(), |
| 254 | }; |
| 255 | let visible: Vec<&ChecklistItemSnapshot> = snapshot.items.iter().take(cap).collect(); |
| 256 | let omitted = snapshot.items.len().saturating_sub(visible.len()); |
| 257 | |
| 258 | for item in visible { |
| 259 | let (marker, color) = checklist_status_marker(&item.status); |
| 260 | let prefix = format!("{marker} "); |
| 261 | // Reserve room for the rail + marker prefix when wrapping content. |
| 262 | let prefix_width = |
| 263 | UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str()); |
| 264 | let content_width = usize::from(width).saturating_sub(prefix_width).max(1); |
| 265 | for (idx, part) in wrap_text(item.content.trim(), content_width) |
| 266 | .into_iter() |
| 267 | .enumerate() |
| 268 | { |
| 269 | let mut spans = vec![Span::styled( |
| 270 | "\u{258F} ".to_string(), |
| 271 | Style::default().fg(palette::TEXT_DIM), |
| 272 | )]; |
| 273 | if idx == 0 { |
| 274 | spans.push(Span::styled(prefix.clone(), Style::default().fg(color))); |
| 275 | } else { |
| 276 | spans.push(Span::raw( |
| 277 | " ".repeat(UnicodeWidthStr::width(prefix.as_str())), |
| 278 | )); |
| 279 | } |
| 280 | spans.push(Span::styled(part, tool_value_style())); |
| 281 | lines.push(Line::from(spans)); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | if omitted > 0 { |
| 286 | lines.push(render_card_detail_line_single( |
| 287 | None, |
| 288 | &format!( |
| 289 | "+{omitted} more; {}", |
| 290 | crate::tui::key_shortcuts::tool_details_shortcut_action_hint("list") |
| 291 | ), |
| 292 | Style::default().fg(palette::TEXT_DIM), |
| 293 | )); |
| 294 | } |
| 295 | |
| 296 | lines |
| 297 | } |
| 298 |