use super::*;
use crate::output::{ActivityKind, NormalizedUsageAggregate};
pub(crate) struct TaskFacts<'a> {
pub(crate) label: &'a str,
pub(crate) intent: Option<&'a str>,
pub(crate) status: Option<ActivityStatus>,
pub(crate) fields: &'a [(String, String)],
pub(crate) failure: Option<&'a str>,
pub(crate) usage: Option<NormalizedUsageAggregate>,
pub(crate) live_tail: &'a [String],
}
pub(crate) struct TaskSummary {
pub(crate) label: String,
pub(crate) intent: String,
pub(crate) status: CardStatus,
pub(crate) body: String,
pub(crate) identity: Option<String>,
pub(crate) agent: Option<String>,
pub(crate) model_line: Option<String>,
pub(crate) usage: Option<NormalizedUsageAggregate>,
pub(crate) live_tail: Vec<String>,
}
pub(crate) fn project_task(facts: &TaskFacts<'_>) -> TaskSummary {
let field = |key: &str| {
facts
.fields
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| compact_line(value))
.filter(|text| !text.trim().is_empty())
};
let label = compact_line(facts.label);
let model_line = field("provider")
.zip(field("model"))
.map(|(provider, model)| {
field("reasoning").map_or_else(
|| format!("{provider}/{model}"),
|reasoning| format!("{provider}/{model}({reasoning})"),
)
});
TaskSummary {
intent: facts
.intent
.map(compact_line)
.unwrap_or_else(|| label.clone()),
label,
status: match facts.status {
Some(ActivityStatus::Queued) | None => CardStatus::None,
Some(status) => card_status_from_activity(status),
},
body: facts
.failure
.and_then(first_non_empty_line)
.map(|line| compact_line(&line))
.unwrap_or_default(),
identity: field("identity"),
agent: field("agent"),
model_line: model_line.map(|text| compact_line(&text)),
usage: facts.usage,
live_tail: facts
.live_tail
.iter()
.rev()
.take(32)
.rev()
.map(|text| compact_line(text))
.collect(),
}
}
pub(crate) fn task_counts(statuses: impl IntoIterator<Item = CardStatus>) -> (usize, usize, usize) {
statuses
.into_iter()
.fold((0, 0, 0), |(done, failed, running), status| match status {
CardStatus::Success | CardStatus::Canceled => (done + 1, failed, running),
CardStatus::Failed => (done, failed + 1, running),
CardStatus::Running => (done, failed, running + 1),
CardStatus::None => (done, failed, running),
})
}
pub(crate) struct TaskActivityFacts<'a> {
pub(crate) kind: &'a ActivityKind,
pub(crate) status: ActivityStatus,
pub(crate) label: &'a str,
pub(crate) permitted_preview: &'a str,
pub(crate) tool_detail: Option<&'a ToolActivityDetail>,
pub(crate) reasoning_summary: Option<(Option<usize>, Option<&'a str>)>,
}
pub(crate) fn project_task_activity(facts: &TaskActivityFacts<'_>) -> Vec<String> {
if let Some((count, latest)) = facts.reasoning_summary {
return match latest.filter(|text| !text.trim().is_empty()) {
Some(latest) => vec![match count {
Some(count) if count > 1 => {
format!("reasoning summaries ×{count} • {}", compact_line(latest))
}
_ => format!("reasoning • {}", compact_line(latest)),
}],
None => vec![compact_line(facts.label)],
};
}
if *facts.kind == ActivityKind::Tool {
let (name, label) = facts.tool_detail.map_or(("tool", facts.label), |detail| {
(detail.tool_name.as_ref(), detail.label.as_ref())
});
let mark = match facts.status {
ActivityStatus::Queued => "○",
ActivityStatus::Running | ActivityStatus::Writing => "●",
ActivityStatus::Success => "✓",
ActivityStatus::Failed => "✗",
ActivityStatus::Canceled => "⊘",
ActivityStatus::Interrupted => "?",
};
let target = (ToolCapability::from_dispatch_name(name).is_some()
|| name == crate::code_mode::TOOL_NAME)
.then(|| compact_line(strip_tool_prefix(label, name)))
.filter(|label| !label.is_empty() && !label.eq_ignore_ascii_case(name));
return vec![target.map_or_else(
|| format!("{} • {mark}", compact_line(name)),
|target| format!("{} • {target} • {mark}", compact_line(name)),
)];
}
if facts.label.trim_start().starts_with("reasoning") {
return Vec::new();
}
let (prefix, source) = match facts.kind {
ActivityKind::Assistant | ActivityKind::SubagentTask => ("agent", facts.permitted_preview),
ActivityKind::Diagnostic => (
"diagnostic",
if facts.permitted_preview.is_empty() {
facts.label
} else {
facts.permitted_preview
},
),
ActivityKind::Hook => ("hook", facts.label),
ActivityKind::ProviderContextInjection => ("provider context injection", facts.label),
ActivityKind::SubagentBatch => ("subagent batch", facts.label),
ActivityKind::Compaction => ("compaction", facts.label),
ActivityKind::Tool => unreachable!(),
};
source
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(32)
.map(|line| {
format!(
"{prefix} • {}",
compact_line(strip_tool_prefix(line, prefix))
)
})
.collect()
}
fn strip_tool_prefix<'a>(label: &'a str, tool: &str) -> &'a str {
let Some(prefix) = label.get(..tool.len()) else {
return label;
};
let Some(rest) = label.get(tool.len()..) else {
return label;
};
if prefix.eq_ignore_ascii_case(tool)
&& rest
.chars()
.next()
.is_some_and(|ch| ch.is_whitespace() || matches!(ch, '•' | '·'))
{
rest.trim_start_matches([' ', '\t', '•', '·'])
} else {
label
}
}
fn compact_line(text: &str) -> String {
truncate_preview_line(&sanitized_single_line(text.trim()), PREVIEW_LINE_MAX_CHARS)
}