返回 CodeWhale
checklist.rs
根目录 / crates / tui / src / tui / history / checklist.rs
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
298 lines RUST