1use ratatui::style::Style;
2use ratatui::text::{Line, Span};
3use std::collections::VecDeque;
4use unicode_width::UnicodeWidthStr;
5
6use super::{GenerationStatus, truncate_to_cells};
7use crate::render::theme::Theme;
8use mermaid_domain::QueuedMessage;
9
10const MAX_QUEUED_ROWS: usize = 5;
12
13const MAX_AGENT_ROWS: usize = 6;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct AgentPanelRow {
22 pub description: String,
23 pub activity: String,
25 pub tokens: usize,
27 pub elapsed_secs: u64,
28 pub backgrounded: bool,
30}
31
32#[expect(clippy::too_many_arguments)]
42#[expect(
43 clippy::too_many_lines,
44 reason = "predates the lint; see .github/baselines/expect_budget.txt"
45)]
46#[must_use]
47pub fn build_status_lines(
48 status: GenerationStatus,
49 elapsed_secs: u64,
50 tokens_received: usize,
51 tokens_estimated: bool,
52 status_override: Option<&str>,
53 agents: &[AgentPanelRow],
54 bg_available: bool,
55 task_headline: Option<&str>,
56 queued_messages: &VecDeque<QueuedMessage>,
57 exit_armed: bool,
58 theme: &Theme,
59 width: u16,
60) -> Vec<Line<'static>> {
61 if (status == GenerationStatus::Idle && agents.is_empty()) || width < 10 {
64 return Vec::new();
65 }
66 let width = width as usize;
67
68 let status_text = match (task_headline, status_override) {
76 (Some(head), _) => head.to_string(),
77 (None, Some(text)) => text.to_string(),
78 (None, None) => status.display_text().to_string(),
79 };
80
81 let info_style = Style::new().fg(theme.colors.info.to_color());
82 let meta_style = Style::new()
83 .fg(theme.colors.text_secondary.to_color())
84 .dim();
85
86 let (arrow, flow_direction) = match status {
90 GenerationStatus::Sending => ("↑ ", "upstream"),
91 GenerationStatus::Thinking | GenerationStatus::Streaming => ("↓ ", "downstream"),
92 GenerationStatus::RunningTools => ("• ", "tools"),
93 GenerationStatus::Compacting => ("• ", "compaction"),
94 GenerationStatus::Cancelling => ("• ", "cleanup"),
95 GenerationStatus::Idle => ("", ""),
96 };
97
98 let bg_hint = if status == GenerationStatus::RunningTools && bg_available {
102 " • ctrl+b to background"
103 } else {
104 ""
105 };
106
107 let exit_hint = if exit_armed {
110 "ctrl+c again to exit • "
111 } else {
112 ""
113 };
114
115 let head = format!("{status_text}... ");
116 let meta = format!(
117 "({exit_hint}esc to interrupt{bg_hint} • {}s • {} {}{} tokens)",
118 elapsed_secs,
119 match flow_direction {
122 "downstream" | "tools" => "↓",
123 "compaction" => "compact",
124 "cleanup" => "cleanup",
125 _ => "↑",
126 },
127 if tokens_estimated { "~" } else { "" },
128 tokens_received
129 );
130
131 let arrow_w = arrow.width();
132 let single_w = arrow_w + head.width() + meta.width();
133
134 let mut lines: Vec<Line<'static>> = Vec::new();
135 if status == GenerationStatus::Idle {
136 } else if single_w <= width {
138 lines.push(Line::from(vec![
140 Span::styled(arrow, info_style),
141 Span::styled(head, info_style),
142 Span::styled(meta, meta_style),
143 ]));
144 } else {
145 let head_budget = width.saturating_sub(arrow_w);
148 lines.push(Line::from(vec![
149 Span::styled(arrow, info_style),
150 Span::styled(truncate_to_cells(head.trim_end(), head_budget), info_style),
151 ]));
152 lines.push(Line::from(vec![
153 Span::raw(" "),
154 Span::styled(
155 truncate_to_cells(&meta, width.saturating_sub(2)),
156 meta_style,
157 ),
158 ]));
159 }
160
161 for row in agents.iter().take(MAX_AGENT_ROWS) {
166 let marker = if row.backgrounded { "◦ bg " } else { "◦ " };
167 let desc = format!(" {marker}{}", row.description);
168 let mut bits: Vec<String> = Vec::new();
169 if !row.activity.is_empty() {
170 bits.push(row.activity.clone());
171 }
172 bits.push(format!("{}s", row.elapsed_secs));
173 if row.tokens > 0 {
174 bits.push(format!(
175 "↓ ~{} tokens",
176 mermaid_domain::compaction::format_compact_count(row.tokens)
177 ));
178 }
179 let desc_budget = width.min(desc.width());
180 let meta_budget = width.saturating_sub(desc_budget + 2);
181 let mut spans = vec![Span::styled(
182 truncate_to_cells(&desc, width),
183 Style::new().fg(theme.colors.info.to_color()),
184 )];
185 if meta_budget > 3 {
186 spans.push(Span::styled(
187 format!(" {}", truncate_to_cells(&bits.join(" · "), meta_budget)),
188 meta_style,
189 ));
190 }
191 lines.push(Line::from(spans));
192 }
193 if agents.len() > MAX_AGENT_ROWS {
194 lines.push(Line::from(vec![Span::styled(
195 format!(" … +{} more", agents.len() - MAX_AGENT_ROWS),
196 meta_style,
197 )]));
198 }
199
200 let body_budget = width.saturating_sub(2); for queued in queued_messages.iter().take(MAX_QUEUED_ROWS) {
203 lines.push(Line::from(vec![Span::styled(
204 format!("> {}", truncate_to_cells(&queued.text, body_budget)),
205 Style::new()
206 .fg(theme.colors.text_primary.to_color())
207 .bg(theme.colors.queued_bg.to_color()),
208 )]));
209 }
210
211 lines
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::render::theme::Theme;
218
219 fn row_width(line: &Line<'_>) -> usize {
220 line.spans.iter().map(|s| s.content.as_ref().width()).sum()
221 }
222
223 #[test]
224 fn long_task_headline_splits_and_fits_width() {
225 let theme = Theme::dark();
226 let queued = VecDeque::new();
227 let lines = build_status_lines(
228 GenerationStatus::RunningTools,
229 3,
230 0,
231 false,
232 None,
233 &[],
234 true,
235 Some("Rewiring the provider factory so runtime toggles ride on ChatRequest end to end"),
236 &queued,
237 false,
238 &theme,
239 80,
240 );
241 assert_eq!(lines.len(), 2, "a too-wide status splits onto two rows");
243 for l in &lines {
244 assert!(
245 row_width(l) <= 80,
246 "row exceeds width: {} > 80",
247 row_width(l)
248 );
249 }
250 }
251
252 #[test]
253 fn running_tools_headline_is_the_bare_phase_word() {
254 let theme = Theme::dark();
259 let queued = VecDeque::new();
260 let lines = build_status_lines(
261 GenerationStatus::RunningTools,
262 11,
263 169,
264 false,
265 None,
266 &[],
267 true,
268 None,
269 &queued,
270 false,
271 &theme,
272 120,
273 );
274 let text: String = lines
275 .iter()
276 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
277 .collect();
278 assert!(
279 text.contains("Running tools..."),
280 "bare phase word expected: {text}"
281 );
282 assert!(
283 !text.contains(':'),
284 "no tool detail may follow the phase word: {text}"
285 );
286 }
287
288 #[test]
289 fn thinking_shows_downstream_arrow_and_live_token_count() {
290 let theme = Theme::dark();
293 let queued = VecDeque::new();
294 let lines = build_status_lines(
295 GenerationStatus::Thinking,
296 7,
297 1_234,
298 true,
299 None,
300 &[],
301 true,
302 None,
303 &queued,
304 false,
305 &theme,
306 120,
307 );
308 let text: String = lines
309 .iter()
310 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
311 .collect();
312 assert!(text.contains("1234"), "must show the live count: {text}");
313 assert!(
314 text.contains('↓'),
315 "thinking receives tokens (downstream): {text}"
316 );
317 assert!(!text.contains('↑'), "thinking is not upstream: {text}");
318 }
319
320 #[test]
321 fn unbreakable_long_headline_is_truncated_not_overflowed() {
322 let theme = Theme::dark();
325 let queued = VecDeque::new();
326 let lines = build_status_lines(
327 GenerationStatus::RunningTools,
328 1,
329 0,
330 false,
331 None,
332 &[],
333 true,
334 Some("Editing D:/Code/AI/some/very/deeply/nested/directory/structure/longfilename.rs"),
335 &queued,
336 false,
337 &theme,
338 40,
339 );
340 for l in &lines {
341 assert!(
342 row_width(l) <= 40,
343 "no row may exceed width even for an unbreakable path: {} > 40",
344 row_width(l)
345 );
346 }
347 }
348
349 #[test]
350 fn short_status_stays_one_row() {
351 let theme = Theme::dark();
352 let queued = VecDeque::new();
353 let lines = build_status_lines(
354 GenerationStatus::Sending,
355 0,
356 0,
357 false,
358 None,
359 &[],
360 true,
361 None,
362 &queued,
363 false,
364 &theme,
365 120,
366 );
367 assert_eq!(lines.len(), 1, "a short status stays on one row");
368 }
369
370 #[test]
371 fn height_is_stable_as_metadata_ticks() {
372 let theme = Theme::dark();
375 let queued = VecDeque::new();
376 let headline = Some("Running the full local gate across every workspace crate and target");
377 let n0 = build_status_lines(
378 GenerationStatus::RunningTools,
379 9,
380 99,
381 false,
382 None,
383 &[],
384 true,
385 headline,
386 &queued,
387 false,
388 &theme,
389 100,
390 )
391 .len();
392 for (elapsed, tokens) in [(10, 100), (999, 100000), (3600, 999999)] {
393 let n = build_status_lines(
394 GenerationStatus::RunningTools,
395 elapsed,
396 tokens,
397 false,
398 None,
399 &[],
400 true,
401 headline,
402 &queued,
403 false,
404 &theme,
405 100,
406 )
407 .len();
408 assert_eq!(n, n0, "row count must not change as counters tick");
409 }
410 }
411
412 #[test]
413 fn armed_exit_shows_second_press_hint() {
414 let theme = Theme::dark();
415 let queued = VecDeque::new();
416 let lines = build_status_lines(
417 GenerationStatus::Streaming,
418 2,
419 10,
420 true,
421 None,
422 &[],
423 true,
424 None,
425 &queued,
426 true,
427 &theme,
428 120,
429 );
430 let text: String = lines
431 .iter()
432 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
433 .collect();
434 assert!(
435 text.contains("ctrl+c again to exit"),
436 "armed exit must surface the second-press hint: {text}"
437 );
438 }
439
440 #[test]
441 fn idle_status_is_empty() {
442 let theme = Theme::dark();
443 let queued = VecDeque::new();
444 let lines = build_status_lines(
445 GenerationStatus::Idle,
446 0,
447 0,
448 false,
449 None,
450 &[],
451 true,
452 None,
453 &queued,
454 false,
455 &theme,
456 80,
457 );
458 assert!(lines.is_empty(), "idle has no status row");
459 }
460}