use std::time::Duration;
use crate::brain::agent::ProgressEvent;
pub(crate) const MAX_VISIBLE_STEPS: usize = 12;
const MAX_LINE_CHARS: usize = 140;
const MAX_NOTE_CHARS: usize = 160;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Step {
Tool {
name: String,
context: String,
status: Option<bool>,
},
Note(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Phase {
Running,
Finished,
Failed,
}
pub(crate) fn fold(steps: &mut Vec<Step>, event: &ProgressEvent) -> bool {
match event {
ProgressEvent::ToolStarted {
tool_name,
tool_input,
..
} => {
steps.push(Step::Tool {
name: tool_name.clone(),
context: crate::utils::sanitize::redact_secrets(&crate::utils::tool_context_hint(
tool_name, tool_input,
)),
status: None,
});
true
}
ProgressEvent::ToolCompleted {
tool_name, success, ..
} => {
let open = steps.iter_mut().rev().find_map(|step| match step {
Step::Tool { name, status, .. }
if status.is_none() && name.as_str() == tool_name.as_str() =>
{
Some(status)
}
_ => None,
});
match open {
Some(status) => *status = Some(*success),
None => steps.push(Step::Tool {
name: tool_name.clone(),
context: String::new(),
status: Some(*success),
}),
}
true
}
ProgressEvent::IntermediateText { text, .. } => {
let clean = clean_text(text);
if clean.is_empty() {
return false;
}
steps.push(Step::Note(clean));
true
}
_ => false,
}
}
pub(crate) const OPTIONS_TOOL: &str = "suggest_options";
pub(crate) fn take_trailing_answer(steps: &mut Vec<Step>) -> Option<String> {
let end = steps
.iter()
.rposition(|s| matches!(s, Step::Tool { name, .. } if name == OPTIONS_TOOL))
.unwrap_or(steps.len());
let start = steps[..end]
.iter()
.rposition(|s| !matches!(s, Step::Note(_)))
.map_or(0, |i| i + 1);
let notes: Vec<String> = steps[start..end]
.iter()
.filter_map(|s| match s {
Step::Note(text) => Some(text.clone()),
Step::Tool { .. } => None,
})
.collect();
if notes.is_empty() {
return None;
}
steps.drain(start..end);
Some(notes.join("\n\n"))
}
pub(crate) fn drop_held(content: &str, held: &[String]) -> String {
let held: Vec<String> = held
.iter()
.flat_map(|h| h.split("\n\n").map(|p| p.trim().to_string()))
.filter(|p| !p.is_empty())
.collect();
content
.split("\n\n")
.filter(|p| !held.iter().any(|h| h == p.trim()))
.collect::<Vec<_>>()
.join("\n\n")
}
pub(crate) fn render(steps: &[Step], phase: Phase, elapsed: Duration) -> String {
let tools = steps
.iter()
.filter(|step| matches!(step, Step::Tool { .. }))
.count();
let calls = if tools == 1 {
"1 tool call".to_string()
} else {
format!("{tools} tool calls")
};
let clock = fmt_elapsed(elapsed);
let header = match phase {
Phase::Running => format!("⚙️ *Working* · {calls} · ⏱ {clock}"),
Phase::Finished => format!("✅ *Finished* · {calls} · ⏱ {clock}"),
Phase::Failed => format!("❌ *Failed* · {calls} · ⏱ {clock}"),
};
let mut lines = vec![header];
let hidden = steps.len().saturating_sub(MAX_VISIBLE_STEPS);
if hidden > 0 {
lines.push(format!("… {hidden} earlier steps"));
}
lines.extend(steps[hidden..].iter().map(render_step));
lines.join("\n")
}
fn render_step(step: &Step) -> String {
match step {
Step::Tool {
name,
context,
status,
} => {
let icon = match status {
None => "⚙️",
Some(true) => "✅",
Some(false) => "❌",
};
let label = if context.is_empty() {
name.clone()
} else {
format!("{name}: {context}")
};
format!("{icon} {}", clip(&label, MAX_LINE_CHARS))
}
Step::Note(text) => {
let one_line = text.split_whitespace().collect::<Vec<_>>().join(" ");
format!("💬 {}", clip(&one_line, MAX_NOTE_CHARS))
}
}
}
fn clean_text(text: &str) -> String {
let (clean, _) = crate::utils::extract_img_markers(text);
let clean = crate::utils::sanitize::redact_secrets(&clean);
crate::utils::sanitize::strip_llm_artifacts(&clean)
.trim()
.to_string()
}
fn clip(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let kept: String = text.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
fn fmt_elapsed(elapsed: Duration) -> String {
let secs = elapsed.as_secs();
if secs < 60 {
format!("{secs}s")
} else {
format!("{}m {}s", secs / 60, secs % 60)
}
}