Skip to main content

vtcode_ui/tui/core_tui/session/
header.rs

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