use ratatui::style::Style;
use ratatui::text::{Line, Span};
use std::collections::VecDeque;
use unicode_width::UnicodeWidthStr;
use super::{GenerationStatus, truncate_line_to_cells, truncate_to_cells};
use crate::render::markdown::parse_markdown_inline;
use crate::render::theme::Theme;
use mermaid_domain::QueuedMessage;
const MAX_QUEUED_ROWS: usize = 5;
const MAX_AGENT_ROWS: usize = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentPanelRow {
pub description: String,
pub activity: String,
pub tokens: usize,
pub elapsed_secs: u64,
pub backgrounded: bool,
}
pub(crate) const SPINNER_FRAMES: [&str; 4] = ["◐ ", "◓ ", "◑ ", "◒ "];
const SPINNER_FRAME_MS: u128 = 150;
#[must_use]
pub fn spinner_glyph(elapsed: std::time::Duration) -> &'static str {
let index = (elapsed.as_millis() / SPINNER_FRAME_MS) % SPINNER_FRAMES.len() as u128;
SPINNER_FRAMES[index as usize]
}
#[expect(clippy::too_many_arguments)]
#[must_use]
pub fn build_status_lines(
status: GenerationStatus,
elapsed: std::time::Duration,
tokens_received: Option<usize>,
status_override: Option<&str>,
agents: &[AgentPanelRow],
bg_available: bool,
task_headline: Option<&str>,
queued_messages: &VecDeque<QueuedMessage>,
exit_armed: bool,
theme: &Theme,
width: u16,
) -> Vec<Line<'static>> {
if (status == GenerationStatus::Idle && agents.is_empty()) || width < 10 {
return Vec::new();
}
let width = width as usize;
let status_text = match (task_headline, status_override) {
(Some(head), _) => head.to_string(),
(None, Some(text)) => text.to_string(),
(None, None) => status.display_text().to_string(),
};
let info_style = Style::new().fg(theme.colors.info.to_color());
let meta_style = Style::new().fg(theme.colors.text_meta.to_color());
let arrow = if status == GenerationStatus::Idle {
""
} else {
spinner_glyph(elapsed)
};
let bg_hint = if status == GenerationStatus::RunningTools && bg_available {
" · ctrl+b to background"
} else {
""
};
let exit_hint = if exit_armed {
"ctrl+c again to exit · "
} else {
""
};
let head_text = format!("{status_text}...");
let parsed_head = parse_markdown_inline(&head_text, theme, info_style);
let head_w: usize = parsed_head.spans.iter().map(|s| s.content.width()).sum();
let tokens = tokens_received.map_or_else(String::new, |n| {
format!(
" · ↓ {} tokens",
mermaid_domain::compaction::format_compact_count(n)
)
});
let meta = format!(
"({exit_hint}esc to interrupt{bg_hint} · {}s{tokens})",
elapsed.as_secs()
);
let arrow_w = arrow.width();
let single_w = arrow_w + head_w + 1 + meta.width();
let mut lines: Vec<Line<'static>> = Vec::new();
if status == GenerationStatus::Idle {
} else if single_w <= width {
let mut row_spans = vec![Span::styled(arrow, info_style)];
row_spans.extend(parsed_head.spans);
row_spans.push(Span::styled(" ", info_style));
row_spans.push(Span::styled(meta, meta_style));
lines.push(Line::from(row_spans));
} else {
let head_budget = width.saturating_sub(arrow_w);
let truncated_head = truncate_line_to_cells(parsed_head, head_budget);
let mut head_row_spans = vec![Span::styled(arrow, info_style)];
head_row_spans.extend(truncated_head.spans);
lines.push(Line::from(head_row_spans));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
truncate_to_cells(&meta, width.saturating_sub(2)),
meta_style,
),
]));
}
push_agent_rows(&mut lines, agents, theme, width);
let body_budget = width.saturating_sub(2); for queued in queued_messages.iter().take(MAX_QUEUED_ROWS) {
lines.push(Line::from(vec![Span::styled(
format!("> {}", truncate_to_cells(&queued.text, body_budget)),
Style::new()
.fg(theme.colors.text_primary.to_color())
.bg(theme.colors.queued_bg.to_color()),
)]));
}
lines
}
fn push_agent_rows(
lines: &mut Vec<Line<'static>>,
agents: &[AgentPanelRow],
theme: &Theme,
width: usize,
) {
let meta_style = Style::new().fg(theme.colors.text_meta.to_color());
for row in agents.iter().take(MAX_AGENT_ROWS) {
let marker = if row.backgrounded { "◦ bg " } else { "◦ " };
let desc = format!(" {marker}{}", row.description);
let mut bits: Vec<String> = Vec::new();
if !row.activity.is_empty() {
bits.push(row.activity.clone());
}
bits.push(format!("{}s", row.elapsed_secs));
if row.tokens > 0 {
bits.push(format!(
"↓ {} tokens",
mermaid_domain::compaction::format_compact_count(row.tokens)
));
}
let desc_budget = width.min(desc.width());
let meta_budget = width.saturating_sub(desc_budget + 2);
let mut spans = vec![Span::styled(
truncate_to_cells(&desc, width),
Style::new().fg(theme.colors.info.to_color()),
)];
if meta_budget > 3 {
spans.push(Span::styled(
format!(" {}", truncate_to_cells(&bits.join(" · "), meta_budget)),
meta_style,
));
}
lines.push(Line::from(spans));
}
if agents.len() > MAX_AGENT_ROWS {
lines.push(Line::from(vec![Span::styled(
format!(" … +{} more", agents.len() - MAX_AGENT_ROWS),
meta_style,
)]));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::theme::Theme;
fn row_width(line: &Line<'_>) -> usize {
line.spans.iter().map(|s| s.content.as_ref().width()).sum()
}
#[test]
fn long_task_headline_splits_and_fits_width() {
let theme = Theme::dark();
let queued = VecDeque::new();
let lines = build_status_lines(
GenerationStatus::RunningTools,
std::time::Duration::from_secs(3),
Some(0),
None,
&[],
true,
Some("Rewiring the provider factory so runtime toggles ride on ChatRequest end to end"),
&queued,
false,
&theme,
80,
);
assert_eq!(lines.len(), 2, "a too-wide status splits onto two rows");
for l in &lines {
assert!(
row_width(l) <= 80,
"row exceeds width: {} > 80",
row_width(l)
);
}
}
#[test]
fn running_tools_headline_is_the_bare_phase_word() {
let theme = Theme::dark();
let queued = VecDeque::new();
let lines = build_status_lines(
GenerationStatus::RunningTools,
std::time::Duration::from_secs(11),
Some(169),
None,
&[],
true,
None,
&queued,
false,
&theme,
120,
);
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
text.contains("Running tools..."),
"bare phase word expected: {text}"
);
assert!(
!text.contains(':'),
"no tool detail may follow the phase word: {text}"
);
}
#[test]
fn thinking_shows_downstream_arrow_and_live_token_count() {
let theme = Theme::dark();
let queued = VecDeque::new();
let lines = build_status_lines(
GenerationStatus::Thinking,
std::time::Duration::from_secs(7),
Some(1_234),
None,
&[],
true,
None,
&queued,
false,
&theme,
120,
);
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
text.contains("1.2k"),
"must show the live count, compact: {text}"
);
assert!(
text.contains('↓'),
"thinking receives tokens (downstream): {text}"
);
assert!(!text.contains('↑'), "thinking is not upstream: {text}");
}
#[test]
fn unbreakable_long_headline_is_truncated_not_overflowed() {
let theme = Theme::dark();
let queued = VecDeque::new();
let lines = build_status_lines(
GenerationStatus::RunningTools,
std::time::Duration::from_secs(1),
Some(0),
None,
&[],
true,
Some("Editing D:/Code/AI/some/very/deeply/nested/directory/structure/longfilename.rs"),
&queued,
false,
&theme,
40,
);
for l in &lines {
assert!(
row_width(l) <= 40,
"no row may exceed width even for an unbreakable path: {} > 40",
row_width(l)
);
}
}
#[test]
fn short_status_stays_one_row() {
let theme = Theme::dark();
let queued = VecDeque::new();
let lines = build_status_lines(
GenerationStatus::Sending,
std::time::Duration::from_secs(0),
Some(0),
None,
&[],
true,
None,
&queued,
false,
&theme,
120,
);
assert_eq!(lines.len(), 1, "a short status stays on one row");
}
#[test]
fn height_is_stable_as_metadata_ticks() {
let theme = Theme::dark();
let queued = VecDeque::new();
let headline = Some("Running the full local gate across every workspace crate and target");
let n0 = build_status_lines(
GenerationStatus::RunningTools,
std::time::Duration::from_secs(9),
Some(99),
None,
&[],
true,
headline,
&queued,
false,
&theme,
100,
)
.len();
for (elapsed, tokens) in [(10, 100), (999, 100000), (3600, 999999)] {
let n = build_status_lines(
GenerationStatus::RunningTools,
std::time::Duration::from_secs(elapsed),
Some(tokens),
None,
&[],
true,
headline,
&queued,
false,
&theme,
100,
)
.len();
assert_eq!(n, n0, "row count must not change as counters tick");
}
}
#[test]
fn armed_exit_shows_second_press_hint() {
let theme = Theme::dark();
let queued = VecDeque::new();
let lines = build_status_lines(
GenerationStatus::Streaming,
std::time::Duration::from_secs(2),
Some(10),
None,
&[],
true,
None,
&queued,
true,
&theme,
120,
);
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
text.contains("ctrl+c again to exit"),
"armed exit must surface the second-press hint: {text}"
);
}
#[test]
fn idle_status_is_empty() {
let theme = Theme::dark();
let queued = VecDeque::new();
let lines = build_status_lines(
GenerationStatus::Idle,
std::time::Duration::from_secs(0),
None,
None,
&[],
true,
None,
&queued,
false,
&theme,
80,
);
assert!(lines.is_empty(), "idle has no status row");
}
}