use anyhow::Result;
use serde_json::Value;
use vtcode_commons::ui_protocol::TaskItemStatus;
use vtcode_core::tools::handlers::task_tracking::{compact_task_tree_view_from_items, short_task_description};
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_ui::tui::app::TaskPanelMetadata;
pub(crate) fn tracker_progress_lines(val: &Value) -> Vec<String> {
if tracker_response_is_successful(val)
&& (!tracker_visible_tree_rows(val).is_empty() || tracker_progress_counts(val).is_some())
{
return vec![tracker_progress_header(val)];
}
let diagnostics = tracker_summary_lines(val);
if diagnostics.is_empty() {
return Vec::new();
}
let mut lines = Vec::with_capacity(diagnostics.len() + 1);
lines.push("• Tasks".to_string());
lines.extend(diagnostics);
lines
}
pub(crate) const TRACKER_TRANSCRIPT_MAX_ROWS: usize = 30;
pub(crate) const TRACKER_ROW_DESCRIPTION_MAX_BYTES: usize = 96;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TrackerLine {
pub(crate) text: String,
pub(crate) status: Option<TaskItemStatus>,
}
impl TrackerLine {
pub(crate) fn plain(text: String) -> Self {
Self { text, status: None }
}
pub(crate) fn row(text: String, status: TaskItemStatus) -> Self {
Self { text, status: Some(status) }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TrackerRow {
pub(crate) text: String,
pub(crate) status: TaskItemStatus,
pub(crate) leaf: bool,
}
pub(crate) fn tracker_transcript_lines(val: &Value, expanded: bool) -> Vec<TrackerLine> {
if !expanded {
let progress = tracker_progress_lines(val);
if progress.len() != 1 || !tracker_response_is_successful(val) {
return progress.into_iter().map(TrackerLine::plain).collect();
}
let Some(current) = tracker_current_tree_row(val) else {
return progress.into_iter().map(TrackerLine::plain).collect();
};
let header = progress.into_iter().next().unwrap_or_else(|| "• Tasks".to_string());
return vec![TrackerLine::plain(header), current];
}
if !tracker_response_is_successful(val) {
return tracker_progress_lines(val).into_iter().map(TrackerLine::plain).collect();
}
let progress = tracker_progress_lines(val);
if progress.len() != 1 {
return progress.into_iter().map(TrackerLine::plain).collect();
}
let tree = tracker_rich_tree_rows(val);
if tree.is_empty() {
return progress.into_iter().map(TrackerLine::plain).collect();
}
let header = progress.into_iter().next().unwrap_or_else(|| "• Tasks".to_string());
let mut lines = Vec::with_capacity(1 + TRACKER_TRANSCRIPT_MAX_ROWS + 1);
lines.push(TrackerLine::plain(header));
if tree.len() > TRACKER_TRANSCRIPT_MAX_ROWS {
let overflow = tree.len() - TRACKER_TRANSCRIPT_MAX_ROWS;
lines.extend(
tree.into_iter()
.take(TRACKER_TRANSCRIPT_MAX_ROWS)
.map(|row| TrackerLine::row(row.text, row.status)),
);
lines.push(TrackerLine::plain(format!(" … {overflow} more")));
} else {
lines.extend(tree.into_iter().map(|row| TrackerLine::row(row.text, row.status)));
}
lines
}
pub(crate) fn tracker_tree_body_lines(val: &Value) -> Vec<String> {
tracker_rich_tree_rows(val).into_iter().map(|row| row.text).collect()
}
pub(crate) fn tracker_panel_rows(val: &Value) -> (Vec<String>, Vec<TaskItemStatus>, Option<usize>) {
let rows = tracker_rich_tree_rows(val);
let current = tracker_current_row_index(&rows);
let (texts, statuses) = rows.into_iter().map(|row| (row.text, row.status)).unzip();
(texts, statuses, current)
}
fn tracker_current_row_index(rows: &[TrackerRow]) -> Option<usize> {
for want_leaf in [true, false] {
for status in [
TaskItemStatus::InProgress,
TaskItemStatus::Pending,
TaskItemStatus::Blocked,
] {
if let Some(index) = rows.iter().position(|row| row.status == status && row.leaf == want_leaf) {
return Some(index);
}
}
}
None
}
fn tracker_rich_tree_rows(val: &Value) -> Vec<TrackerRow> {
let checklist_items = val
.get("checklist")
.and_then(Value::as_object)
.and_then(|checklist| checklist.get("items"))
.and_then(Value::as_array);
let compact_rows = checklist_items
.filter(|items| !items.is_empty())
.map(|items| compact_task_tree_view_from_items(items))
.unwrap_or_default();
if compact_rows.is_empty() {
return val
.get("view")
.and_then(Value::as_object)
.and_then(|view| view.get("lines"))
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(visible_tracker_view_row)
.map(|display| match tracker_tree_row_glyph(&display) {
Some((glyph, _)) => TrackerRow {
text: tracker_row_text(strip_tracker_status_glyph(&display)),
status: task_status_from_glyph(glyph),
leaf: true,
},
None => TrackerRow {
text: tracker_row_text(display),
status: TaskItemStatus::Pending,
leaf: false,
},
})
.collect()
})
.unwrap_or_default();
}
let mut rows = Vec::with_capacity(compact_rows.len());
for row in &compact_rows {
let Some(display) = visible_tracker_view_row(row) else {
continue;
};
let status = row
.get("status")
.and_then(Value::as_str)
.and_then(|raw| raw.parse::<TaskItemStatus>().ok())
.unwrap_or(TaskItemStatus::Pending);
let leaf = tracker_tree_row_glyph(&display).is_some();
rows.push(TrackerRow {
text: tracker_row_text(strip_tracker_status_glyph(&display)),
status,
leaf,
});
}
rows
}
fn tracker_visible_tree_rows(val: &Value) -> Vec<String> {
tracker_tree_body_lines(val)
}
pub(crate) fn is_tracker_current_row(line: &str) -> bool {
line.trim_start().starts_with("▶ ")
}
pub(crate) fn tracker_current_tree_row(val: &Value) -> Option<TrackerLine> {
let rows = tracker_rich_tree_rows(val);
let row = rows.get(tracker_current_row_index(&rows)?)?;
Some(TrackerLine::row(format_tracker_current_row(&row.text), row.status))
}
fn task_status_from_glyph(glyph: &str) -> TaskItemStatus {
match glyph {
"[x] " => TaskItemStatus::Completed,
"[-] " => TaskItemStatus::InProgress,
"[!] " => TaskItemStatus::Blocked,
_ => TaskItemStatus::Pending,
}
}
fn tracker_tree_row_glyph(display: &str) -> Option<(&str, &str)> {
let mut rest = display.trim_start();
loop {
if let Some(after) = rest
.strip_prefix("├ ")
.or_else(|| rest.strip_prefix("└ "))
.or_else(|| rest.strip_prefix("│ "))
{
rest = after;
continue;
}
break;
}
for glyph in ["□ ", "[x] ", "[-] ", "[!] "] {
if let Some(body) = rest.strip_prefix(glyph) {
return Some((glyph, body));
}
}
None
}
pub(super) fn tracker_row_text(text: String) -> String {
let (prefix, body) = split_tree_prefix_for_shortening(&text);
let short = short_task_description(body);
let base = if short.trim().is_empty() {
body.trim()
} else {
short.trim()
};
let recombined = format!("{prefix}{base}");
vtcode_commons::formatting::truncate_byte_budget(&recombined, TRACKER_ROW_DESCRIPTION_MAX_BYTES, "…")
}
fn split_tree_prefix_for_shortening(text: &str) -> (&str, &str) {
let mut index = 0;
while index < text.len() && text.as_bytes()[index] == b' ' {
index += 1;
}
loop {
let rest = &text[index..];
if let Some(after) = rest
.strip_prefix("├ ")
.or_else(|| rest.strip_prefix("└ "))
.or_else(|| rest.strip_prefix("│ "))
{
index = text.len() - after.len();
continue;
}
break;
}
text.split_at(index)
}
fn strip_tracker_status_glyph(display: &str) -> String {
let Some((_, body)) = tracker_tree_row_glyph(display) else {
return display.to_string();
};
let prefix_len = display.len() - body.len();
let (prefix_with_glyph, _) = display.split_at(prefix_len);
let glyph_len = ["□ ", "[x] ", "[-] ", "[!] "]
.iter()
.find_map(|glyph| prefix_with_glyph.strip_suffix(glyph).map(|_| glyph.len()))
.unwrap_or(0);
format!("{}{}", &prefix_with_glyph[..prefix_with_glyph.len() - glyph_len], body.trim_start())
}
fn format_tracker_current_row(glyphless_text: &str) -> String {
let mut rest = glyphless_text.trim_start();
loop {
if let Some(after) = rest
.strip_prefix("├ ")
.or_else(|| rest.strip_prefix("└ "))
.or_else(|| rest.strip_prefix("│ "))
{
rest = after;
continue;
}
break;
}
format!(" ▶ {}", rest.trim_start())
}
fn tracker_progress_header(val: &Value) -> String {
let Some((completed, total)) = tracker_progress_counts(val) else {
return "• Tasks".to_string();
};
let label = resolve_tracker_title(val, "Tasks");
format!("• {label} {completed}/{total}")
}
fn tracker_progress_counts(val: &Value) -> Option<(usize, usize)> {
let checklist = val.get("checklist")?.as_object()?;
if let (Some(completed), Some(total)) =
(checklist.get("completed").and_then(Value::as_u64), checklist.get("total").and_then(Value::as_u64))
{
return Some((usize::try_from(completed).unwrap_or(usize::MAX), usize::try_from(total).unwrap_or(usize::MAX)));
}
let items = checklist.get("items")?.as_array()?;
let renderable = items
.iter()
.filter(|item| {
item.get("description")
.and_then(Value::as_str)
.or_else(|| item.get("text").and_then(Value::as_str))
.is_some_and(|desc| !desc.trim().is_empty())
})
.collect::<Vec<_>>();
if renderable.is_empty() {
return None;
}
let total = renderable.len();
let completed = renderable
.iter()
.filter(|item| item.get("status").and_then(Value::as_str) == Some("completed"))
.count();
Some((completed, total))
}
pub(crate) fn humanize_tracker_title(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return "Task tracker".to_string();
}
let mut parts = trimmed.splitn(2, ['-', '_']);
let prefix = parts.next().unwrap_or_default();
let slug = parts.next().unwrap_or_default();
let is_generated_id = prefix.len() >= 10
&& prefix.chars().all(|character| character.is_ascii_digit())
&& !slug.is_empty()
&& slug
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_');
if !is_generated_id {
return trimmed.to_string();
}
let humanized = slug
.split(['-', '_'])
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ");
if humanized.is_empty() {
trimmed.to_string()
} else {
humanized
}
}
fn visible_tracker_view_row(value: &Value) -> Option<String> {
let display = value.get("display").and_then(Value::as_str).or_else(|| value.as_str())?;
let trimmed = display.trim_start();
if trimmed.starts_with("files:") || trimmed.starts_with("outcome:") || trimmed.starts_with("verify:") {
return None;
}
Some(display.to_string())
}
pub(crate) fn tracker_panel_metadata(val: &Value) -> Option<TaskPanelMetadata> {
val.get("checklist").and_then(Value::as_object)?;
let title = resolve_tracker_title(val, "Task tracker");
let (completed, total) = tracker_progress_counts(val)?;
Some(TaskPanelMetadata { title, completed, total })
}
fn resolve_tracker_title(val: &Value, fallback_default: &str) -> String {
let raw = val
.get("checklist")
.and_then(Value::as_object)
.and_then(|checklist| checklist.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|title| !title.is_empty());
let Some(raw) = raw else {
return first_item_descriptive_title(val).unwrap_or_else(|| fallback_default.to_string());
};
if is_generated_tracker_title(raw) {
return first_item_descriptive_title(val).unwrap_or_else(|| fallback_default.to_string());
}
humanize_tracker_title(raw)
}
fn is_generated_tracker_title(raw: &str) -> bool {
let trimmed = raw.trim();
let mut parts = trimmed.splitn(2, ['-', '_']);
let prefix = parts.next().unwrap_or_default();
let slug = parts.next().unwrap_or_default();
let is_timestamped =
prefix.len() >= 10 && prefix.chars().all(|character| character.is_ascii_digit()) && !slug.is_empty();
if is_timestamped {
return true;
}
vtcode_commons::slug::is_humanized_codename(&humanize_tracker_title(trimmed))
}
fn first_item_descriptive_title(val: &Value) -> Option<String> {
let items = val.get("checklist")?.get("items")?.as_array()?;
for item in items {
let description = item
.get("description")
.and_then(Value::as_str)
.or_else(|| item.get("text").and_then(Value::as_str))?;
let short = short_task_description(description);
let short = short.trim();
if !short.is_empty() {
return Some(vtcode_commons::formatting::truncate_byte_budget(short, 60, "…"));
}
}
None
}
pub(super) fn render_tracker_view(renderer: &mut AnsiRenderer, val: &Value) -> Result<bool> {
let expanded = !renderer.is_compact_display();
let lines: Vec<String> = tracker_transcript_lines(val, expanded)
.into_iter()
.map(|line| line.text)
.collect();
if lines.is_empty() {
return Ok(false);
}
for line in lines {
renderer.render_markdown_output(MessageStyle::ToolDetail, &line)?;
}
Ok(true)
}
pub(super) fn tracker_summary_lines(val: &Value) -> Vec<String> {
let has_valid_checklist_items = val
.get("checklist")
.and_then(Value::as_object)
.and_then(|checklist| checklist.get("items"))
.and_then(Value::as_array)
.is_some_and(|items| !compact_task_tree_view_from_items(items).is_empty());
if has_valid_checklist_items && tracker_response_is_successful(val) {
return Vec::new();
}
tracker_diagnostic_lines(val)
}
fn tracker_response_is_successful(val: &Value) -> bool {
if val.get("error").is_some() || val.get("error_type").is_some() {
return false;
}
match val.get("status").and_then(Value::as_str) {
Some("created" | "replaced" | "updated" | "unchanged" | "ok" | "added") => true,
Some(_) => false,
None => true,
}
}
fn tracker_diagnostic_lines(val: &Value) -> Vec<String> {
let mut lines = Vec::new();
if let Some(status) = val.get("status").and_then(Value::as_str)
&& !status.trim().is_empty()
{
lines.push(format!(" Tracker status: {status}"));
}
if let Some(message) = val.get("message").and_then(Value::as_str)
&& !message.trim().is_empty()
{
lines.push(format!(" Update: {message}"));
}
lines
}