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