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