1use std::fmt::Write;
2use std::sync::Arc;
3
4use ratatui::{
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{Block, Paragraph, Wrap},
8};
9use unicode_segmentation::UnicodeSegmentation;
10
11use crate::tui::config::constants::ui;
12use crate::tui::core_tui::blocked_status;
13use crate::tui::core_tui::style::convert_style;
14
15use super::super::types::{InlineHeaderContext, InlineHeaderHighlight};
16use super::terminal_capabilities;
17use super::utils::line_truncation::truncate_line_with_ellipsis_if_overflow;
18use super::{Session, ratatui_color_from_ansi, ratatui_style_from_inline};
19
20fn ultrafast_badge_style(colors_enabled: bool, safe_colors_only: bool, bold_is_bright: bool) -> Style {
21 let mut style = Style::default().remove_modifier(Modifier::DIM | Modifier::REVERSED);
22 style = if !colors_enabled {
23 style.fg(Color::Reset).bg(Color::Reset).add_modifier(Modifier::REVERSED)
24 } else if safe_colors_only {
25 style.fg(Color::Black).bg(Color::Yellow)
26 } else {
27 style.fg(Color::Rgb(0, 0, 0)).bg(Color::Rgb(0xD9, 0x9A, 0x4E))
29 };
30 if bold_is_bright {
31 style.remove_modifier(Modifier::BOLD)
32 } else {
33 style.add_modifier(Modifier::BOLD)
34 }
35}
36
37fn clean_reasoning_text(text: &str) -> String {
38 vtcode_commons::formatting::clean_reasoning_text(text)
39}
40
41fn capitalize_first_letter(s: &str) -> String {
42 let mut chars = s.chars();
43 match chars.next() {
44 None => String::new(),
45 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
46 }
47}
48
49fn format_model_summary_label(model: &str) -> String {
50 model.split('-').map(capitalize_first_letter).collect::<Vec<_>>().join("-")
51}
52
53pub(crate) fn primary_agent_header_label(name: Option<&str>) -> String {
54 let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
55 return "Build".to_string();
56 };
57 format_model_summary_label(name)
58}
59
60fn compact_context_window_label(context_window_size: usize) -> String {
61 if context_window_size >= 1_000_000 {
62 format!("{}M", context_window_size / 1_000_000)
63 } else if context_window_size >= 1_000 {
64 format!("{}K", context_window_size / 1_000)
65 } else {
66 context_window_size.to_string()
67 }
68}
69
70fn line_is_empty(spans: &[Span<'static>]) -> bool {
71 spans.len() == 1 && spans.first().is_some_and(|span| span.content.is_empty())
72}
73
74impl Session {
75 pub(crate) fn header_lines(&mut self) -> Arc<Vec<Line<'static>>> {
76 if let Some(cached) = &self.header_lines_cache {
77 return Arc::clone(cached);
78 }
79
80 let lines = if self.appearance.hide_header {
81 vec![self.header_block_title_for_width(self.transcript_width)]
82 } else {
83 vec![self.header_compact_line()]
84 };
85 let arc = Arc::new(lines);
86 self.header_lines_cache = Some(Arc::clone(&arc));
87 arc
88 }
89
90 pub(crate) fn header_block_title_cached(&mut self) -> Line<'static> {
92 if let Some(title) = &self.header_block_title_cache {
93 return title.clone();
94 }
95 let title = self.header_block_title();
96 self.header_block_title_cache = Some(title.clone());
97 title
98 }
99
100 pub(crate) fn header_height_from_lines(&mut self, width: u16, lines: &[Line<'static>]) -> u16 {
101 if self.appearance.hide_header {
102 return 1;
103 }
104
105 if width == 0 {
106 return self.header_rows.max(ui::INLINE_HEADER_HEIGHT);
107 }
108
109 if let Some(&height) = self.header_height_cache.get(&width) {
110 return height;
111 }
112
113 let paragraph = self.build_header_paragraph(lines);
114 let measured = paragraph.line_count(width);
115 let resolved = u16::try_from(measured).unwrap_or(u16::MAX);
116 let resolved = resolved.clamp(ui::INLINE_HEADER_HEIGHT, 3);
118 self.header_height_cache.insert(width, resolved);
119 resolved
120 }
121
122 pub(crate) fn build_header_paragraph(&self, lines: &[Line<'static>]) -> Paragraph<'static> {
123 let text_style = self.header_primary_style().add_modifier(Modifier::DIM);
124
125 if self.appearance.hide_header {
126 return Paragraph::new(lines.to_vec()).style(text_style).wrap(Wrap { trim: true });
127 }
128
129 let mut border_style = Style::default();
130 if let Some(accent) = self.theme.tool_accent.or(self.theme.primary).or(self.theme.foreground) {
131 border_style = border_style.fg(ratatui_color_from_ansi(accent));
132 }
133 let text_style = self.header_primary_style().add_modifier(Modifier::DIM);
134 let block = Block::bordered()
135 .title(self.header_block_title())
136 .border_type(terminal_capabilities::get_border_type())
137 .border_style(border_style)
138 .style(self.styles.default_style());
139
140 Paragraph::new(lines.to_vec())
141 .style(text_style)
142 .wrap(Wrap { trim: true })
143 .block(block)
144 }
145
146 #[cfg(test)]
147 pub(crate) fn header_height_for_width(&mut self, width: u16) -> u16 {
148 let lines = self.header_lines();
149 self.header_height_from_lines(width, lines.as_slice())
150 }
151
152 fn header_block_title(&self) -> Line<'static> {
153 self.header_block_title_for_width(0)
154 }
155
156 fn header_block_title_for_width(&self, width: u16) -> Line<'static> {
157 let fallback = InlineHeaderContext::default();
158 let version = if self.header_context.version.trim().is_empty() {
159 fallback.version
160 } else {
161 self.header_context.version.clone()
162 };
163
164 let app_name = if self.header_context.app_name.trim().is_empty() {
165 ui::HEADER_VERSION_PREFIX
166 } else {
167 self.header_context.app_name.trim()
168 };
169 let prompt = format!("{}{}", ui::HEADER_VERSION_PROMPT, app_name);
170 let version_text =
171 format!(" {}{}{}", ui::HEADER_VERSION_LEFT_DELIMITER, version.trim(), ui::HEADER_VERSION_RIGHT_DELIMITER);
172
173 let prompt_style = self.section_title_style();
174 let version_style = self.header_secondary_style().add_modifier(Modifier::DIM);
175
176 let mut spans = vec![
177 Span::styled(prompt, prompt_style),
178 Span::styled(version_text, version_style),
179 ];
180
181 if self.appearance.hide_header && width > 0 {
182 let right_spans = self.header_compact_right_spans();
183 if right_spans.is_empty() {
184 return Line::from(spans);
185 }
186
187 let left_width = spans.iter().map(Span::width).sum::<usize>();
188 let summary_width = right_spans.iter().map(Span::width).sum::<usize>();
189 let available_width = usize::from(width);
190 let spacer_width = available_width.saturating_sub(left_width.saturating_add(summary_width)).max(1);
191 spans.push(Span::raw(" ".repeat(spacer_width)));
192 spans.extend(right_spans);
193 }
194
195 let line = Line::from(spans);
196 if width > 0 {
197 truncate_line_with_ellipsis_if_overflow(line, usize::from(width))
198 } else {
199 line
200 }
201 }
202
203 fn header_compact_right_spans(&self) -> Vec<Span<'static>> {
204 let mut spans = Vec::new();
205 let agent_label = primary_agent_header_label(self.header_context.primary_agent.as_deref());
206 let model_summary_spans = self.header_compact_model_summary_spans();
207 if !agent_label.trim().is_empty() {
208 let fallback = self.theme.primary.map(ratatui_color_from_ansi).unwrap_or(Color::LightMagenta);
209 let agent_style =
210 super::super::style::agent_color_style(self.header_context.primary_agent_color.as_deref(), fallback);
211 spans.push(Span::styled(agent_label, agent_style));
212 }
213
214 if !spans.is_empty() && !model_summary_spans.is_empty() {
215 spans.push(Span::styled(" · ".to_owned(), self.header_secondary_style()));
216 }
217 spans.extend(model_summary_spans);
218
219 spans
220 }
221
222 fn header_compact_model_summary_spans(&self) -> Vec<Span<'static>> {
223 let provider = self.header_provider_short_value();
224 let model = self.header_model_short_value();
225 let reasoning = self.header_reasoning_short_value();
226 let mut spans = Vec::new();
227 let mut provider_model_parts = Vec::new();
228
229 match (
230 !provider.is_empty() && !provider.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
231 !model.is_empty() && !model.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
232 ) {
233 (true, true) => provider_model_parts.push(format!(
234 "{} {}",
235 capitalize_first_letter(&provider),
236 format_model_summary_label(&model)
237 )),
238 (true, false) => provider_model_parts.push(capitalize_first_letter(&provider)),
239 (false, true) => provider_model_parts.push(format_model_summary_label(&model)),
240 (false, false) => {}
241 }
242
243 if let Some(summary) = (!provider_model_parts.is_empty()).then(|| provider_model_parts.join(" ")) {
244 spans.push(Span::styled(summary, self.header_secondary_style().add_modifier(Modifier::DIM)));
245 }
246
247 if !reasoning.is_empty() && !reasoning.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER) {
248 if !spans.is_empty() {
249 spans.push(Span::styled(" · ".to_owned(), self.header_secondary_style()));
250 }
251 let value_style = self.header_secondary_style().add_modifier(Modifier::ITALIC);
252 spans.push(Span::styled(reasoning, value_style));
253 }
254
255 if let Some(tier) = self.header_service_tier_short_value() {
256 if !spans.is_empty() {
257 spans.push(Span::styled(" · ".to_owned(), self.header_secondary_style()));
258 }
259 spans.push(self.header_service_tier_span(&tier));
260 }
261
262 spans
263 }
264
265 pub(crate) fn header_title_line(&self) -> Line<'static> {
266 let mut spans = Vec::new();
268
269 let provider = self.header_provider_short_value();
270 let model = self.header_model_short_value();
271 let reasoning = self.header_reasoning_short_value();
272
273 if !provider.is_empty() {
274 let capitalized_provider = capitalize_first_letter(&provider);
275 let mut style = self.header_primary_style();
276 style = style.add_modifier(Modifier::BOLD);
277 spans.push(Span::styled(capitalized_provider, style));
278 }
279
280 if !model.is_empty() {
281 if !spans.is_empty() {
282 spans.push(Span::raw(" "));
283 }
284 let mut style = self.header_primary_style();
285 style = style.add_modifier(Modifier::ITALIC);
286 spans.push(Span::styled(model, style));
287 }
288
289 if !reasoning.is_empty() {
290 if !spans.is_empty() {
291 spans.push(Span::raw(" "));
292 }
293 let mut style = self.header_secondary_style();
294 style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
295
296 if let Some(stage) = &self.header_context.reasoning_stage {
297 let mut stage_style = style;
298 stage_style = stage_style.remove_modifier(Modifier::DIM).add_modifier(Modifier::BOLD);
299 spans.push(Span::styled(format!("[{stage}]"), stage_style));
300 spans.push(Span::raw(" "));
301 }
302
303 spans.push(Span::styled(reasoning.to_string(), style));
304 }
305
306 if let Some(tier) = self.header_service_tier_short_value() {
307 if !spans.is_empty() {
308 spans.push(Span::raw(" "));
309 }
310 spans.push(self.header_service_tier_span(&tier));
311 }
312
313 if spans.is_empty() {
314 spans.push(Span::raw(String::new()));
315 }
316
317 Line::from(spans)
318 }
319
320 fn header_compact_line(&self) -> Line<'static> {
321 let mut spans = self.header_title_line().spans;
322 if line_is_empty(&spans) {
323 spans.clear();
324 }
325
326 let mut meta_spans = self.header_meta_line().spans;
327 if line_is_empty(&meta_spans) {
328 meta_spans.clear();
329 }
330
331 let mut tail_spans = if self.should_show_suggestions() {
332 self.header_suggestions_line()
333 } else {
334 self.header_highlights_line()
335 }
336 .map(|line| line.spans)
337 .unwrap_or_default();
338
339 if line_is_empty(&tail_spans) {
340 tail_spans.clear();
341 }
342
343 let separator_style = self.header_secondary_style();
344 let separator = Span::styled(ui::HEADER_PRIMARY_SEPARATOR.to_owned(), separator_style);
345
346 let mut append_section = |section: &mut Vec<Span<'static>>| {
347 if section.is_empty() {
348 return;
349 }
350 if !spans.is_empty() {
351 spans.push(separator.clone());
352 }
353 spans.append(section);
354 };
355
356 append_section(&mut meta_spans);
357 append_section(&mut tail_spans);
358
359 if spans.is_empty() {
360 spans.push(Span::raw(String::new()));
361 }
362
363 Line::from(spans)
364 }
365
366 fn header_provider_value(&self) -> String {
367 let trimmed = self.header_context.provider.trim();
368 if trimmed.is_empty() {
369 InlineHeaderContext::default().provider
370 } else {
371 self.header_context.provider.clone()
372 }
373 }
374
375 fn header_model_value(&self) -> String {
376 let trimmed = self.header_context.model.trim();
377 if trimmed.is_empty() {
378 InlineHeaderContext::default().model
379 } else {
380 self.header_context.model.clone()
381 }
382 }
383
384 fn header_reasoning_value(&self) -> Option<String> {
385 let raw_reasoning = &self.header_context.reasoning;
386 let cleaned = clean_reasoning_text(raw_reasoning);
387 let trimmed = cleaned.trim();
388 let value = if trimmed.is_empty() {
389 InlineHeaderContext::default().reasoning
390 } else {
391 cleaned
392 };
393 if value.trim().is_empty() { None } else { Some(value) }
394 }
395
396 fn header_provider_short_value(&self) -> String {
397 let value = self.header_provider_value();
398 Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX).trim().to_owned()
399 }
400
401 fn header_model_short_value(&self) -> String {
402 let value = self.header_model_value();
403 let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX).trim().to_owned();
404
405 match self.header_context.context_window_size {
406 Some(context_window_size) if context_window_size > 0 => {
407 format!("{} ({})", model, compact_context_window_label(context_window_size))
408 }
409 _ => model,
410 }
411 }
412
413 fn header_reasoning_short_value(&self) -> String {
414 let value = self.header_reasoning_value().unwrap_or_default();
415 Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX).trim().to_owned()
416 }
417
418 fn header_service_tier_short_value(&self) -> Option<String> {
419 let raw = self.header_context.service_tier.as_deref()?;
420 let tier = Self::strip_prefix(raw, ui::HEADER_SERVICE_TIER_PREFIX).trim();
421 (!tier.is_empty() && !tier.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER)).then(|| tier.to_owned())
422 }
423
424 fn header_service_tier_span(&self, tier: &str) -> Span<'static> {
425 if tier.eq_ignore_ascii_case("ultrafast") || tier.eq_ignore_ascii_case("fast") {
426 Span::styled(
427 if tier.eq_ignore_ascii_case("fast") {
428 " FAST "
429 } else {
430 " ULTRAFAST "
431 },
432 ultrafast_badge_style(
433 vtcode_commons::color_policy::current_color_output_policy().enabled,
434 crate::theme::is_safe_colors_only(),
435 crate::theme::is_bold_bright_mode(),
436 ),
437 )
438 } else {
439 Span::styled(capitalize_first_letter(tier), self.header_secondary_style())
440 }
441 }
442
443 pub fn header_chain_values(&self) -> Vec<String> {
444 let mut values = Vec::new();
445
446 for value in [
447 &self.header_context.tools,
448 &self.header_context.git,
449 &self.header_context.mcp,
450 ] {
451 let trimmed = value.trim();
452 if trimmed.is_empty() {
453 continue;
454 }
455
456 if trimmed.starts_with(ui::HEADER_TOOLS_PREFIX) || trimmed.starts_with(ui::HEADER_GIT_PREFIX) {
457 continue;
458 }
459
460 if let Some(body) = trimmed.strip_prefix(ui::HEADER_MCP_PREFIX) {
461 let body = body.trim();
462 if body.is_empty() || body.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER) {
463 continue;
464 }
465 values.push(format!("MCP: {body}"));
466 continue;
467 }
468
469 values.push(trimmed.to_owned());
470 }
471
472 values
473 }
474
475 pub(crate) fn header_meta_line(&self) -> Line<'static> {
476 let mut spans = Vec::new();
477
478 let mut first_section = true;
479 let separator_style = self.header_secondary_style();
480
481 let push_badge = |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
482 if !*first {
483 spans.push(Span::styled(ui::HEADER_SECONDARY_SEPARATOR.to_owned(), separator_style));
484 }
485 spans.push(Span::styled(text, style));
486 *first = false;
487 };
488
489 let agent_badge_color = self
490 .theme
491 .primary
492 .or(self.theme.foreground)
493 .map(ratatui_color_from_ansi)
494 .unwrap_or(Color::LightMagenta);
495 let agent_style = Style::default().fg(agent_badge_color).add_modifier(Modifier::BOLD);
496 push_badge(
497 &mut spans,
498 primary_agent_header_label(self.header_context.primary_agent.as_deref()),
499 agent_style,
500 &mut first_section,
501 );
502
503 let trust_value = self.header_context.workspace_trust.to_lowercase();
505 if trust_value.contains("full auto") || trust_value.contains("full_auto") {
506 let auto_badge_color = self
507 .theme
508 .tool_accent
509 .or(self.theme.primary)
510 .or(self.theme.foreground)
511 .map(ratatui_color_from_ansi)
512 .unwrap_or(Color::LightCyan);
513 let badge_style = Style::default().fg(auto_badge_color).add_modifier(Modifier::BOLD);
514 push_badge(&mut spans, "Full-auto".to_string(), badge_style, &mut first_section);
515 } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
516 let safe_badge_color = self
517 .theme
518 .primary
519 .or(self.theme.foreground)
520 .map(ratatui_color_from_ansi)
521 .unwrap_or(Color::LightGreen);
522 let badge_style = Style::default().fg(safe_badge_color).add_modifier(Modifier::BOLD);
523 push_badge(&mut spans, "Safe".to_string(), badge_style, &mut first_section);
524 }
525
526 if let Some(badge) = self.header_context.subagent_badges.first() {
528 let mut badge_style = ratatui_style_from_inline(&badge.style, self.theme.foreground);
529 if badge.full_background {
530 badge_style = badge_style.add_modifier(Modifier::BOLD);
531 }
532 let hidden = self.header_context.subagent_badges.len().saturating_sub(1);
533 let label = if hidden == 0 {
534 badge.text.clone()
535 } else {
536 format!("{} +{}", badge.text, hidden)
537 };
538 push_badge(&mut spans, format!(" {label} "), badge_style, &mut first_section);
539 }
540
541 if let Some(badge) = self.background_header_badge_text() {
546 let style = if self.has_background_activity() {
547 self.header_primary_style()
550 .remove_modifier(Modifier::DIM)
551 .add_modifier(Modifier::BOLD)
552 } else {
553 self.header_secondary_style().add_modifier(Modifier::DIM)
554 };
555 push_badge(&mut spans, badge, style, &mut first_section);
556 }
557
558 let is_blocked_header = matches!(
563 self.activity_state,
564 vtcode_commons::ui_protocol::ActivityState::Blocked | vtcode_commons::ui_protocol::ActivityState::Recovery
565 ) || self
566 .input_status_right
567 .as_deref()
568 .is_some_and(blocked_status::right_status_is_blocked)
569 || self
570 .input_status_left
571 .as_deref()
572 .is_some_and(blocked_status::left_status_is_blocked);
573 if is_blocked_header {
574 let blocked_inline = convert_style(crate::theme::active_styles().error);
579 let blocked_style = ratatui_style_from_inline(&blocked_inline, self.theme.foreground);
580 push_badge(&mut spans, "Blocked".to_string(), blocked_style, &mut first_section);
581 }
582
583 if spans.is_empty() {
584 spans.push(Span::raw(String::new()));
585 }
586
587 Line::from(spans)
588 }
589
590 fn header_highlights_line(&self) -> Option<Line<'static>> {
591 let mut spans = Vec::new();
592 let mut first_section = true;
593
594 for highlight in &self.header_context.highlights {
595 let title = highlight.title.trim();
596 let summary = self.header_highlight_summary(highlight);
597
598 if title.is_empty() && summary.is_none() {
599 continue;
600 }
601
602 if !first_section {
603 spans.push(Span::styled(ui::HEADER_META_SEPARATOR.to_owned(), self.header_secondary_style()));
604 }
605
606 if !title.is_empty() {
607 let mut title_style = self.header_secondary_style();
608 title_style = title_style.add_modifier(Modifier::BOLD);
609 let mut title_text = title.to_owned();
610 if summary.is_some() {
611 title_text.push(':');
612 }
613 spans.push(Span::styled(title_text, title_style));
614 if summary.is_some() {
615 spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
616 }
617 }
618
619 if let Some(body) = summary {
620 spans.push(Span::styled(body, self.header_primary_style()));
621 }
622
623 first_section = false;
624 }
625
626 if spans.is_empty() {
627 None
628 } else {
629 Some(Line::from(spans))
630 }
631 }
632
633 fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
634 let entries: Vec<String> = highlight
635 .lines
636 .iter()
637 .map(|line| line.trim())
638 .filter(|line| !line.is_empty())
639 .map(|line| {
640 let stripped = line.strip_prefix("- ").or_else(|| line.strip_prefix("• ")).unwrap_or(line);
641 stripped.trim().to_owned()
642 })
643 .collect();
644
645 if entries.is_empty() {
646 return None;
647 }
648
649 Some(self.compact_highlight_entries(&entries))
650 }
651
652 fn compact_highlight_entries(&self, entries: &[String]) -> String {
653 let mut summary = self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
654 if entries.len() > 1 {
655 let remaining = entries.len() - 1;
656 if !summary.is_empty() {
657 let _ = write!(summary, " (+{remaining} more)");
658 } else {
659 summary = format!("(+{remaining} more)");
660 }
661 }
662 summary
663 }
664
665 fn truncate_highlight_preview(&self, text: &str) -> String {
666 let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
667 if max == 0 {
668 return String::new();
669 }
670
671 let grapheme_count = text.graphemes(true).count();
672 if grapheme_count <= max {
673 return text.to_owned();
674 }
675
676 let mut truncated = String::new();
680 truncated.reserve(text.len().min(max * 4));
682 for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
683 truncated.push_str(grapheme);
684 }
685 truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
686 truncated
687 }
688
689 fn should_show_suggestions(&self) -> bool {
691 self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
693 }
694
695 pub(crate) fn header_suggestions_line(&self) -> Option<Line<'static>> {
697 let dim = self.header_secondary_style().add_modifier(Modifier::DIM);
698 let key = self.header_primary_style().add_modifier(Modifier::BOLD);
699 let label = self.header_secondary_style();
700 let dot = Span::styled(" · ", dim);
701
702 let mut spans = vec![
703 Span::styled("/help", key),
704 dot.clone(),
705 Span::styled("/model", key),
706 dot.clone(),
707 Span::styled("/effort", key),
708 dot.clone(),
709 Span::styled("/config", key),
710 dot.clone(),
711 Span::styled("/clear", key),
712 Span::styled(" │ ", dim),
713 Span::styled("↑↓", key),
714 Span::styled(" nav", label),
715 dot.clone(),
716 Span::styled("Tab", key),
717 Span::styled(" complete", label),
718 ];
719
720 if self.has_local_agents() {
721 spans.push(Span::styled(" │ ", dim));
722 spans.push(Span::styled("Alt+S", key));
723 spans.push(Span::styled(" agents", label));
724 spans.push(dot.clone());
725 spans.push(Span::styled(self.background_shortcut_label().to_owned(), key));
726 spans.push(Span::styled(" background", label));
727 } else if self.has_active_foreground_pty() {
728 spans.push(Span::styled(" │ ", dim));
729 spans.push(Span::styled(self.background_shortcut_label().to_owned(), key));
730 spans.push(Span::styled(" background", label));
731 }
732
733 Some(Line::from(spans))
734 }
735
736 pub(crate) fn section_title_style(&self) -> Style {
737 let mut style = self.styles.default_style().add_modifier(Modifier::BOLD | Modifier::DIM);
738 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
739 style = style.fg(ratatui_color_from_ansi(primary));
740 }
741 style
742 }
743
744 pub(crate) fn header_primary_style(&self) -> Style {
745 let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
746 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
747 style = style.fg(ratatui_color_from_ansi(primary));
748 }
749 style
750 }
751
752 pub(crate) fn header_secondary_style(&self) -> Style {
753 let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
754 if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
755 style = style.fg(ratatui_color_from_ansi(secondary));
756 }
757 style
758 }
759
760 fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
761 value.strip_prefix(prefix).unwrap_or(value)
762 }
763}
764
765#[cfg(test)]
766mod badge_tests {
767 use super::*;
768
769 #[test]
770 fn ultrafast_badge_color_policy_and_bold_is_bright() {
771 for bold_is_bright in [false, true] {
772 for (colors_enabled, safe_colors_only, foreground, background, reversed) in [
773 (true, false, Color::Rgb(0, 0, 0), Color::Rgb(0xD9, 0x9A, 0x4E), false),
774 (true, true, Color::Black, Color::Yellow, false),
775 (false, false, Color::Reset, Color::Reset, true),
776 (false, true, Color::Reset, Color::Reset, true),
777 ] {
778 let style = ultrafast_badge_style(colors_enabled, safe_colors_only, bold_is_bright);
779 let inherited = Style::default()
780 .add_modifier(Modifier::DIM | Modifier::BOLD | Modifier::REVERSED)
781 .patch(style);
782 assert_eq!(inherited.fg, Some(foreground));
783 assert_eq!(inherited.bg, Some(background));
784 assert_eq!(inherited.add_modifier.contains(Modifier::REVERSED), reversed);
785 assert_eq!(inherited.add_modifier.contains(Modifier::BOLD), !bold_is_bright);
786 assert!(!inherited.add_modifier.contains(Modifier::DIM));
787 }
788 }
789 }
790}