feat(tui): concise todo / checklist update rendering (#403)

When the model fires \`todo_update\` / \`checklist_update\` repeatedly
during a long run, the live transcript previously dumped the full
checklist card (header + every item + progress) on every call. In
sessions with 20+ items and a dozen status flips the same item list
appears over and over, drowning the actual work.

Now: when a checklist update output starts with the
"Updated todo #N to STATUS" prefix the tool already emits, the live
renderer shows a compact one-line state-change card —
\`Todo #N: <title> → STATUS\` — plus a \`M/N · pct%\` summary in the
header and a \`N items (Alt+V for full list)\` affordance underneath.
The full item list is still reachable via the existing detail pager.

Falls back to the full-card render path for:
- \`todo_write\` / \`checklist_write\` (no "Updated" prefix — first
  emission of the list)
- transcript-mode replays (the user wants the full snapshot when
  scrolling history)
- malformed prefixes (parse failure → fall through, never crash)

### What's wired

- New \`parse_update_prefix(output)\` parser handles both
  \`Updated todo #N to STATUS\` and \`Updated checklist #N to STATUS\`
  forms.
- New \`render_checklist_change_card\` builds the compact card. Looks
  up the title from the snapshot's items array (id is 1-indexed),
  falls back to \`(missing title)\` if the id is out of range.
- \`try_render_as_checklist\` calls the change-card path only in
  \`RenderMode::Live\` and only when the parser matches. Pre-existing
  cases (writes, transcript replay) keep the full-card behavior.

### Tests

- 4 parser tests: todo form, checklist form, write outputs falling
  through, malformed prefixes falling through.
- 2 renderer tests: compact card shows only the changed item (with
  assertions that other titles do NOT appear), missing-title path.

### Verification

cargo fmt --all -- --check                                          ✓
cargo clippy --workspace --all-targets --all-features --locked --   -D warnings   ✓
cargo test --workspace --all-features --locked                      ✓ 1834 + supporting

Closes #403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-03 03:36:21 -05:00
parent 4d4a9b424c
commit c52f2c46f1
+284
View File
@@ -1306,6 +1306,27 @@ impl GenericToolCell {
}
let output = self.output.as_ref()?;
let snapshot = parse_checklist_snapshot(output)?;
// Concise update rendering (#403). When the tool emits an
// "Updated todo #N to STATUS" prefix line — which `todo_update` /
// `checklist_update` always do on a successful match — render
// only the changed item plus a `M/N · pct%` summary instead of
// dumping the full list every time. The full list is still
// reachable via Alt+V on the tool detail record. This keeps the
// transcript scannable in long sessions.
if matches!(mode, RenderMode::Live)
&& let Some(change) = parse_update_prefix(output)
{
return Some(render_checklist_change_card(
&self.name,
self.status,
&snapshot,
&change,
width,
low_motion,
));
}
Some(render_checklist_card(
&self.name,
self.status,
@@ -1412,6 +1433,119 @@ fn parse_checklist_snapshot(output: &str) -> Option<ChecklistSnapshot> {
})
}
/// One parsed "Updated todo #N to STATUS" prefix line emitted by
/// `todo_update` / `checklist_update`. Used by [`render_checklist_change_card`]
/// to show a compact state-change line instead of the full item list.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ChecklistChange {
id: u32,
status: String,
}
/// Parse the leading line of a checklist-update tool output. Returns
/// `None` for non-update outputs (e.g. `todo_write` snapshots, errors,
/// or an unexpected format) so the caller falls back to the full-list
/// renderer.
fn parse_update_prefix(output: &str) -> Option<ChecklistChange> {
// The tool output shape is `Updated todo #3 to in_progress\n{ ... }`.
// We tolerate `checklist` or `todo` as the noun and any reasonable
// status word (the snapshot lookup in the renderer is the source of
// truth for the title — we just need the id+status pair).
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 })
}
/// Render a compact one-line state-change card for `todo_update` /
/// `checklist_update` calls (#403). Shows the changed item's marker,
/// title, and old → new status, with a `M/N · pct%` progress summary
/// in the header. The full list is still available via Alt+V on the
/// detail record.
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,
));
// Look up the title from the snapshot. `id` in tool input is
// 1-indexed; `items` is 0-indexed.
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("\u{258F} ") + 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));
// Tease that the full list is still available without leaving the
// transcript. Mirrors the same affordance used by other tool cells.
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), // ☑
@@ -2818,6 +2952,156 @@ mod tests {
// Below 3s the label stays "running" — quick reads/greps shouldn't
// visually churn. From 3s onward the badge appears and ticks each
// second so the user can tell the call hasn't hung.
// ---- #403 concise todo / checklist update rendering ----
//
// The tool emits an "Updated todo #N to STATUS" leading line plus a
// JSON snapshot. The renderer should detect the prefix and produce
// a compact one-line state-change card instead of dumping the full
// item list every time.
#[test]
fn parse_update_prefix_recognises_todo_form() {
let parsed =
super::parse_update_prefix("Updated todo #3 to in_progress\n{ \"items\": [...] }");
assert_eq!(
parsed,
Some(super::ChecklistChange {
id: 3,
status: "in_progress".to_string(),
}),
);
}
#[test]
fn parse_update_prefix_recognises_checklist_form() {
let parsed =
super::parse_update_prefix("Updated checklist #7 to completed\n{ \"items\": [] }");
assert_eq!(
parsed,
Some(super::ChecklistChange {
id: 7,
status: "completed".to_string(),
}),
);
}
#[test]
fn parse_update_prefix_returns_none_for_writes() {
// `todo_write` / `checklist_write` outputs don't start with
// "Updated …" — they should fall through to the full-card path.
assert!(super::parse_update_prefix("{ \"items\": [] }").is_none());
assert!(super::parse_update_prefix("Wrote 5 todos\n{}").is_none());
}
#[test]
fn parse_update_prefix_returns_none_for_malformed() {
// Missing arrow/status → fall through.
assert!(super::parse_update_prefix("Updated todo #3\n").is_none());
// Non-numeric id → fall through.
assert!(super::parse_update_prefix("Updated todo #foo to done\n").is_none());
}
#[test]
fn render_checklist_change_card_shows_only_changed_item() {
// Build a snapshot with three items; render the change for #2.
let snapshot = super::ChecklistSnapshot {
items: vec![
super::ChecklistItemSnapshot {
content: "Read the spec".to_string(),
status: "completed".to_string(),
},
super::ChecklistItemSnapshot {
content: "Write the test".to_string(),
status: "in_progress".to_string(),
},
super::ChecklistItemSnapshot {
content: "Land the PR".to_string(),
status: "pending".to_string(),
},
],
completion_pct: 33,
completed: 1,
total: 3,
};
let change = super::ChecklistChange {
id: 2,
status: "in_progress".to_string(),
};
let lines = super::render_checklist_change_card(
"todo_update",
ToolStatus::Success,
&snapshot,
&change,
80,
true,
);
// Header + change line + summary affordance = 3 lines.
assert!(lines.len() >= 3, "expected ≥3 lines, got {}", lines.len());
// The change line should mention the title and the new status,
// and should NOT include the other two item titles (that's the
// whole point — concise rendering).
let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(change_line.contains("#2"), "missing id: {change_line:?}");
assert!(
change_line.contains("Write the test"),
"missing title: {change_line:?}"
);
assert!(
change_line.contains("in_progress"),
"missing status: {change_line:?}"
);
assert!(
!change_line.contains("Land the PR"),
"should not show other items: {change_line:?}"
);
assert!(
!change_line.contains("Read the spec"),
"should not show other items: {change_line:?}"
);
// The summary line carries the count + Alt+V hint.
let summary_line: String = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(summary_line.contains("3 items"), "{summary_line:?}");
assert!(summary_line.contains("Alt+V"), "{summary_line:?}");
}
#[test]
fn render_checklist_change_card_handles_missing_title_gracefully() {
// If the change targets an out-of-range id, the title falls
// back to a placeholder rather than crashing.
let snapshot = super::ChecklistSnapshot {
items: vec![super::ChecklistItemSnapshot {
content: "only item".to_string(),
status: "pending".to_string(),
}],
completion_pct: 0,
completed: 0,
total: 1,
};
let change = super::ChecklistChange {
id: 99,
status: "completed".to_string(),
};
let lines = super::render_checklist_change_card(
"todo_update",
ToolStatus::Success,
&snapshot,
&change,
80,
true,
);
let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(change_line.contains("#99"));
assert!(change_line.contains("(missing title)"));
}
#[test]
fn running_status_label_omits_elapsed_below_threshold() {
assert_eq!(running_status_label_with_elapsed(0), "running");