use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use serde_json::Value;
use unicode_width::UnicodeWidthStr;
use crate::palette;
use super::{
RenderMode, TRANSCRIPT_RAIL, ToolStatus, render_card_detail_line_single, render_compact_kv,
render_tool_header_with_family_and_summary, tool_status_label, tool_value_style, truncate_text,
wrap_text,
};
pub(super) fn is_checklist_tool_name(name: &str) -> bool {
matches!(
name,
"checklist_write"
| "checklist_add"
| "checklist_update"
| "todo_write"
| "todo_add"
| "todo_update"
)
}
#[derive(Debug, Clone)]
pub(super) struct ChecklistItemSnapshot {
pub(super) content: String,
pub(super) status: String,
}
#[derive(Debug, Clone, Default)]
pub(super) struct ChecklistSnapshot {
pub(super) items: Vec<ChecklistItemSnapshot>,
pub(super) completion_pct: u8,
pub(super) completed: usize,
pub(super) total: usize,
}
pub(super) fn parse_checklist_snapshot(output: &str) -> Option<ChecklistSnapshot> {
let json_start = output.find('{')?;
let parsed: Value = serde_json::from_str(&output[json_start..]).ok()?;
let items_value = parsed.get("items")?.as_array()?;
let items: Vec<ChecklistItemSnapshot> = items_value
.iter()
.map(|item| ChecklistItemSnapshot {
content: item
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
status: item
.get("status")
.and_then(Value::as_str)
.unwrap_or("pending")
.to_string(),
})
.collect();
if items.is_empty() {
return None;
}
let completed = items
.iter()
.filter(|item| item.status.eq_ignore_ascii_case("completed"))
.count();
let total = items.len();
let completion_pct = parsed
.get("completion_pct")
.and_then(Value::as_u64)
.map(|pct| u8::try_from(pct.min(100)).unwrap_or(100))
.unwrap_or_else(|| {
(completed * 100)
.checked_div(total)
.and_then(|pct| u8::try_from(pct).ok())
.unwrap_or(0)
});
Some(ChecklistSnapshot {
items,
completion_pct,
completed,
total,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ChecklistChange {
pub(super) id: u32,
pub(super) status: String,
}
pub(super) fn parse_update_prefix(output: &str) -> Option<ChecklistChange> {
let first = output.lines().next()?.trim();
let rest = first
.strip_prefix("Updated todo #")
.or_else(|| first.strip_prefix("Updated checklist #"))?;
let (id_str, after) = rest.split_once(' ')?;
let id: u32 = id_str.parse().ok()?;
let status = after.strip_prefix("to ")?.trim().to_string();
if status.is_empty() {
return None;
}
Some(ChecklistChange { id, status })
}
pub(super) fn render_checklist_change_card(
name: &str,
status: ToolStatus,
snapshot: &ChecklistSnapshot,
change: &ChecklistChange,
width: u16,
low_motion: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let header_summary = format!(
"{}/{} \u{00B7} {}%",
snapshot.completed, snapshot.total, snapshot.completion_pct
);
let family = crate::tui::widgets::tool_card::tool_family_for_name(name);
lines.push(render_tool_header_with_family_and_summary(
family,
Some(&header_summary),
tool_status_label(status),
status,
None,
low_motion,
));
let item = (change.id as usize)
.checked_sub(1)
.and_then(|idx| snapshot.items.get(idx));
let title = item
.map(|i| i.content.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "(missing title)".to_string());
let (marker, marker_color) = checklist_status_marker(&change.status);
let prefix = format!("{marker} ");
let prefix_width =
UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str());
let id_label = format!("Todo #{}", change.id);
let arrow = " \u{2192} ";
let status_label = change.status.clone();
let title_budget = usize::from(width)
.saturating_sub(prefix_width)
.saturating_sub(UnicodeWidthStr::width(id_label.as_str()))
.saturating_sub(UnicodeWidthStr::width(arrow))
.saturating_sub(UnicodeWidthStr::width(status_label.as_str()))
.saturating_sub(2)
.max(8);
let title_truncated = truncate_text(title.as_str(), title_budget);
let spans = vec![
Span::styled(
"\u{258F} ".to_string(),
Style::default().fg(palette::TEXT_DIM),
),
Span::styled(prefix, Style::default().fg(marker_color)),
Span::styled(id_label, Style::default().fg(palette::TEXT_DIM)),
Span::styled(": ".to_string(), Style::default().fg(palette::TEXT_DIM)),
Span::styled(title_truncated, tool_value_style()),
Span::styled(arrow.to_string(), Style::default().fg(palette::TEXT_DIM)),
Span::styled(status_label, Style::default().fg(marker_color)),
];
lines.push(Line::from(spans));
lines.push(render_card_detail_line_single(
None,
&format!(
"{} item{} (Alt+V for full list)",
snapshot.total,
if snapshot.total == 1 { "" } else { "s" }
),
Style::default().fg(palette::TEXT_MUTED),
));
lines
}
fn checklist_status_marker(status: &str) -> (&'static str, Color) {
match status.to_ascii_lowercase().as_str() {
"completed" | "done" => ("\u{2611}", palette::STATUS_SUCCESS), "in_progress" | "inprogress" | "running" => ("\u{25D0}", palette::DEEPSEEK_SKY), "blocked" | "failed" => ("\u{2717}", palette::STATUS_ERROR), "cancelled" | "canceled" | "skipped" => ("\u{2298}", palette::TEXT_MUTED), _ => ("\u{2610}", palette::TEXT_MUTED), }
}
const CHECKLIST_LIVE_ITEM_LIMIT: usize = 8;
pub(super) fn render_checklist_card(
name: &str,
status: ToolStatus,
snapshot: &ChecklistSnapshot,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let header_summary = format!(
"{}/{} \u{00B7} {}%",
snapshot.completed, snapshot.total, snapshot.completion_pct
);
let family = crate::tui::widgets::tool_card::tool_family_for_name(name);
lines.push(render_tool_header_with_family_and_summary(
family,
Some(&header_summary),
tool_status_label(status),
status,
None,
low_motion,
));
lines.extend(render_compact_kv(
"checklist",
name,
tool_value_style(),
width,
));
let cap = match mode {
RenderMode::Live => CHECKLIST_LIVE_ITEM_LIMIT,
RenderMode::Transcript => snapshot.items.len(),
};
let visible: Vec<&ChecklistItemSnapshot> = snapshot.items.iter().take(cap).collect();
let omitted = snapshot.items.len().saturating_sub(visible.len());
for item in visible {
let (marker, color) = checklist_status_marker(&item.status);
let prefix = format!("{marker} ");
let prefix_width =
UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str());
let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
for (idx, part) in wrap_text(item.content.trim(), content_width)
.into_iter()
.enumerate()
{
let mut spans = vec![Span::styled(
"\u{258F} ".to_string(),
Style::default().fg(palette::TEXT_DIM),
)];
if idx == 0 {
spans.push(Span::styled(prefix.clone(), Style::default().fg(color)));
} else {
spans.push(Span::raw(
" ".repeat(UnicodeWidthStr::width(prefix.as_str())),
));
}
spans.push(Span::styled(part, tool_value_style()));
lines.push(Line::from(spans));
}
}
if omitted > 0 {
lines.push(render_card_detail_line_single(
None,
&format!("+{omitted} more (Alt+V for full list)"),
Style::default().fg(palette::TEXT_DIM),
));
}
lines
}