Skip to main content

vtcode_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::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 "Build".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 let Some((mode_text, mode_style)) = self.header_active_mode_summary() {
223            if !spans.is_empty() {
224                spans.push(Span::styled(
225                    " · ".to_owned(),
226                    self.header_secondary_style(),
227                ));
228            }
229            spans.push(Span::styled(mode_text, mode_style));
230        }
231
232        if !spans.is_empty() && !model_summary_spans.is_empty() {
233            spans.push(Span::styled(
234                " · ".to_owned(),
235                self.header_secondary_style(),
236            ));
237        }
238        spans.extend(model_summary_spans);
239
240        spans
241    }
242
243    fn header_compact_model_summary_spans(&self) -> Vec<Span<'static>> {
244        let provider = self.header_provider_short_value();
245        let model = self.header_model_short_value();
246        let reasoning = self.header_reasoning_short_value();
247        let mut spans = Vec::new();
248        let mut provider_model_parts = Vec::new();
249
250        match (
251            !provider.is_empty() && !provider.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
252            !model.is_empty() && !model.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
253        ) {
254            (true, true) => provider_model_parts.push(format!(
255                "{} {}",
256                capitalize_first_letter(&provider),
257                format_model_summary_label(&model)
258            )),
259            (true, false) => provider_model_parts.push(capitalize_first_letter(&provider)),
260            (false, true) => provider_model_parts.push(format_model_summary_label(&model)),
261            (false, false) => {}
262        }
263
264        if let Some(summary) =
265            (!provider_model_parts.is_empty()).then(|| provider_model_parts.join(" "))
266        {
267            spans.push(Span::styled(
268                summary,
269                self.header_secondary_style().add_modifier(Modifier::DIM),
270            ));
271        }
272
273        if !reasoning.is_empty() && !reasoning.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER)
274        {
275            if !spans.is_empty() {
276                spans.push(Span::styled(
277                    " · ".to_owned(),
278                    self.header_secondary_style(),
279                ));
280            }
281            let label_style = self
282                .header_secondary_style()
283                .add_modifier(Modifier::ITALIC | Modifier::DIM);
284            let value_style = self.header_secondary_style().add_modifier(Modifier::ITALIC);
285            spans.push(Span::styled("effort:".to_owned(), label_style));
286            spans.push(Span::styled(" ".to_owned(), label_style));
287            spans.push(Span::styled(reasoning, value_style));
288        }
289
290        spans
291    }
292
293    fn header_active_mode_summary(&self) -> Option<(String, Style)> {
294        use super::super::types::EditingMode;
295
296        if self.header_context.autonomous_mode {
297            return Some((
298                "Auto".to_string(),
299                Style::default()
300                    .fg(Color::Green)
301                    .add_modifier(Modifier::BOLD),
302            ));
303        }
304
305        match self.header_context.editing_mode {
306            EditingMode::Plan => Some((
307                "Plan".to_string(),
308                Style::default()
309                    .fg(Color::Yellow)
310                    .add_modifier(Modifier::BOLD),
311            )),
312            EditingMode::Edit => Some((
313                "Edit".to_string(),
314                Style::default()
315                    .fg(Color::Cyan)
316                    .add_modifier(Modifier::BOLD),
317            )),
318        }
319    }
320
321    pub fn header_title_line(&self) -> Line<'static> {
322        // First line: badge-style provider + model + reasoning summary
323        let mut spans = Vec::new();
324
325        let provider = self.header_provider_short_value();
326        let model = self.header_model_short_value();
327        let reasoning = self.header_reasoning_short_value();
328
329        if !provider.is_empty() {
330            let capitalized_provider = capitalize_first_letter(&provider);
331            let mut style = self.header_primary_style();
332            style = style.add_modifier(Modifier::BOLD);
333            spans.push(Span::styled(capitalized_provider, style));
334        }
335
336        if !model.is_empty() {
337            if !spans.is_empty() {
338                spans.push(Span::raw(" "));
339            }
340            let mut style = self.header_primary_style();
341            style = style.add_modifier(Modifier::ITALIC);
342            spans.push(Span::styled(model, style));
343        }
344
345        if !reasoning.is_empty() {
346            if !spans.is_empty() {
347                spans.push(Span::raw(" "));
348            }
349            let mut style = self.header_secondary_style();
350            style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
351
352            if let Some(stage) = &self.header_context.reasoning_stage {
353                let mut stage_style = style;
354                stage_style = stage_style
355                    .remove_modifier(Modifier::DIM)
356                    .add_modifier(Modifier::BOLD);
357                spans.push(Span::styled(format!("[{}]", stage), stage_style));
358                spans.push(Span::raw(" "));
359            }
360
361            spans.push(Span::styled(reasoning.to_string(), style));
362        }
363
364        if spans.is_empty() {
365            spans.push(Span::raw(String::new()));
366        }
367
368        Line::from(spans)
369    }
370
371    fn header_compact_line(&self) -> Line<'static> {
372        let mut spans = self.header_title_line().spans;
373        if line_is_empty(&spans) {
374            spans.clear();
375        }
376
377        let mut meta_spans = self.header_meta_line().spans;
378        if line_is_empty(&meta_spans) {
379            meta_spans.clear();
380        }
381
382        let mut tail_spans = if self.should_show_suggestions() {
383            self.header_suggestions_line()
384        } else {
385            self.header_highlights_line()
386        }
387        .map(|line| line.spans)
388        .unwrap_or_default();
389
390        if line_is_empty(&tail_spans) {
391            tail_spans.clear();
392        }
393
394        let separator_style = self.header_secondary_style();
395        let separator = Span::styled(
396            ui::HEADER_MODE_PRIMARY_SEPARATOR.to_owned(),
397            separator_style,
398        );
399
400        let mut append_section = |section: &mut Vec<Span<'static>>| {
401            if section.is_empty() {
402                return;
403            }
404            if !spans.is_empty() {
405                spans.push(separator.clone());
406            }
407            spans.append(section);
408        };
409
410        append_section(&mut meta_spans);
411        append_section(&mut tail_spans);
412
413        if spans.is_empty() {
414            spans.push(Span::raw(String::new()));
415        }
416
417        Line::from(spans)
418    }
419
420    fn header_provider_value(&self) -> String {
421        let trimmed = self.header_context.provider.trim();
422        if trimmed.is_empty() {
423            InlineHeaderContext::default().provider
424        } else {
425            self.header_context.provider.clone()
426        }
427    }
428
429    fn header_model_value(&self) -> String {
430        let trimmed = self.header_context.model.trim();
431        if trimmed.is_empty() {
432            InlineHeaderContext::default().model
433        } else {
434            self.header_context.model.clone()
435        }
436    }
437
438    fn header_mode_label(&self) -> String {
439        let trimmed = self.header_context.mode.trim();
440        if trimmed.is_empty() {
441            InlineHeaderContext::default().mode
442        } else {
443            self.header_context.mode.clone()
444        }
445    }
446
447    pub fn header_mode_short_label(&self) -> String {
448        let full = self.header_mode_label();
449        let value = full.trim();
450        if value.eq_ignore_ascii_case(ui::HEADER_MODE_AUTO) {
451            return "Auto".to_owned();
452        }
453        if value.eq_ignore_ascii_case(ui::HEADER_MODE_INLINE) {
454            return "Inline".to_owned();
455        }
456        if value.eq_ignore_ascii_case(ui::HEADER_MODE_ALTERNATE) {
457            return "Alternate".to_owned();
458        }
459
460        // Handle common abbreviations with more descriptive names
461        if value.to_lowercase() == "std" {
462            return "Session: Standard".to_owned();
463        }
464
465        let compact = value
466            .strip_suffix(ui::HEADER_MODE_FULL_AUTO_SUFFIX)
467            .unwrap_or(value)
468            .trim();
469        compact.to_owned()
470    }
471
472    fn header_reasoning_value(&self) -> Option<String> {
473        let raw_reasoning = &self.header_context.reasoning;
474        let cleaned = clean_reasoning_text(raw_reasoning);
475        let trimmed = cleaned.trim();
476        let value = if trimmed.is_empty() {
477            InlineHeaderContext::default().reasoning
478        } else {
479            cleaned
480        };
481        if value.trim().is_empty() {
482            None
483        } else {
484            Some(value)
485        }
486    }
487
488    pub fn header_provider_short_value(&self) -> String {
489        let value = self.header_provider_value();
490        Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX)
491            .trim()
492            .to_owned()
493    }
494
495    pub fn header_model_short_value(&self) -> String {
496        let value = self.header_model_value();
497        let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX)
498            .trim()
499            .to_owned();
500
501        match self.header_context.context_window_size {
502            Some(context_window_size) if context_window_size > 0 => {
503                format!(
504                    "{} ({})",
505                    model,
506                    compact_context_window_label(context_window_size)
507                )
508            }
509            _ => model,
510        }
511    }
512
513    pub fn header_reasoning_short_value(&self) -> String {
514        let value = self.header_reasoning_value().unwrap_or_default();
515        Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX)
516            .trim()
517            .to_owned()
518    }
519
520    pub fn header_chain_values(&self) -> Vec<String> {
521        let mut values = Vec::new();
522
523        for value in [
524            &self.header_context.tools,
525            &self.header_context.git,
526            &self.header_context.mcp,
527        ] {
528            let trimmed = value.trim();
529            if trimmed.is_empty() {
530                continue;
531            }
532
533            if trimmed.starts_with(ui::HEADER_TOOLS_PREFIX)
534                || trimmed.starts_with(ui::HEADER_GIT_PREFIX)
535            {
536                continue;
537            }
538
539            if let Some(body) = trimmed.strip_prefix(ui::HEADER_MCP_PREFIX) {
540                let body = body.trim();
541                if body.is_empty() || body.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER) {
542                    continue;
543                }
544                values.push(format!("MCP: {}", body));
545                continue;
546            }
547
548            values.push(trimmed.to_owned());
549        }
550
551        values
552    }
553
554    pub fn header_meta_line(&self) -> Line<'static> {
555        use super::super::types::EditingMode;
556
557        let mut spans = Vec::new();
558
559        let mut first_section = true;
560        let separator_style = self.header_secondary_style();
561
562        let push_badge =
563            |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
564                if !*first {
565                    spans.push(Span::styled(
566                        ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
567                        separator_style,
568                    ));
569                }
570                spans.push(Span::styled(text, style));
571                *first = false;
572            };
573
574        let agent_style = Style::default()
575            .fg(Color::Magenta)
576            .add_modifier(Modifier::BOLD);
577        push_badge(
578            &mut spans,
579            primary_agent_header_label(self.header_context.primary_agent.as_deref()),
580            agent_style,
581            &mut first_section,
582        );
583
584        // Show editing mode badge
585        if self.header_context.editing_mode == EditingMode::Plan {
586            let badge_style = Style::default()
587                .fg(Color::Yellow)
588                .add_modifier(Modifier::BOLD);
589            push_badge(
590                &mut spans,
591                "Plan".to_string(),
592                badge_style,
593                &mut first_section,
594            );
595        }
596
597        // Show autonomous mode indicator
598        if self.header_context.autonomous_mode {
599            let badge_style = Style::default()
600                .fg(Color::Green)
601                .add_modifier(Modifier::BOLD);
602            push_badge(
603                &mut spans,
604                "Auto".to_string(),
605                badge_style,
606                &mut first_section,
607            );
608        }
609
610        // Show trust level badge
611        let trust_value = self.header_context.workspace_trust.to_lowercase();
612        if trust_value.contains("full auto") || trust_value.contains("full_auto") {
613            let badge_style = Style::default()
614                .fg(Color::Cyan)
615                .add_modifier(Modifier::BOLD);
616            push_badge(
617                &mut spans,
618                "Full-auto".to_string(),
619                badge_style,
620                &mut first_section,
621            );
622        } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
623            let badge_style = Style::default()
624                .fg(Color::Green)
625                .add_modifier(Modifier::BOLD);
626            push_badge(
627                &mut spans,
628                "Safe".to_string(),
629                badge_style,
630                &mut first_section,
631            );
632        }
633
634        if let Some(badge) = self
635            .header_context
636            .persistent_memory
637            .as_ref()
638            .filter(|badge| !badge.text.trim().is_empty())
639        {
640            if !first_section {
641                spans.push(Span::styled(
642                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
643                    self.header_secondary_style(),
644                ));
645            }
646            let style = header_status_badge_style(badge, self.header_primary_style());
647            spans.push(Span::styled(badge.text.clone(), style));
648            first_section = false;
649        }
650
651        if let Some(badge) = self
652            .header_context
653            .pr_review
654            .as_ref()
655            .filter(|badge| !badge.text.trim().is_empty())
656        {
657            if !first_section {
658                spans.push(Span::styled(
659                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
660                    self.header_secondary_style(),
661                ));
662            }
663            let style = header_status_badge_style(badge, self.header_primary_style());
664            spans.push(Span::styled(badge.text.clone(), style));
665            first_section = false;
666        }
667
668        for badge in self
669            .header_context
670            .subagent_badges
671            .iter()
672            .filter(|badge| !badge.text.trim().is_empty())
673        {
674            if !first_section {
675                spans.push(Span::styled(
676                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
677                    self.header_secondary_style(),
678                ));
679            }
680            let text = if badge.full_background {
681                format!(" {} ", badge.text)
682            } else {
683                badge.text.clone()
684            };
685            spans.push(Span::styled(text, header_context_badge_style(badge)));
686            first_section = false;
687        }
688
689        for value in self.header_chain_values() {
690            if !first_section {
691                spans.push(Span::styled(
692                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
693                    self.header_secondary_style(),
694                ));
695            }
696            spans.push(Span::styled(value, self.header_primary_style()));
697            first_section = false;
698        }
699
700        if spans.is_empty() {
701            spans.push(Span::raw(String::new()));
702        }
703
704        Line::from(spans)
705    }
706
707    fn header_highlights_line(&self) -> Option<Line<'static>> {
708        let mut spans = Vec::new();
709        let mut first_section = true;
710
711        for highlight in &self.header_context.highlights {
712            let title = highlight.title.trim();
713            let summary = self.header_highlight_summary(highlight);
714
715            if title.is_empty() && summary.is_none() {
716                continue;
717            }
718
719            if !first_section {
720                spans.push(Span::styled(
721                    ui::HEADER_META_SEPARATOR.to_owned(),
722                    self.header_secondary_style(),
723                ));
724            }
725
726            if !title.is_empty() {
727                let mut title_style = self.header_secondary_style();
728                title_style = title_style.add_modifier(Modifier::BOLD);
729                let mut title_text = title.to_owned();
730                if summary.is_some() {
731                    title_text.push(':');
732                }
733                spans.push(Span::styled(title_text, title_style));
734                if summary.is_some() {
735                    spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
736                }
737            }
738
739            if let Some(body) = summary {
740                spans.push(Span::styled(body, self.header_primary_style()));
741            }
742
743            first_section = false;
744        }
745
746        if spans.is_empty() {
747            None
748        } else {
749            Some(Line::from(spans))
750        }
751    }
752
753    fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
754        let entries: Vec<String> = highlight
755            .lines
756            .iter()
757            .map(|line| line.trim())
758            .filter(|line| !line.is_empty())
759            .map(|line| {
760                let stripped = line
761                    .strip_prefix("- ")
762                    .or_else(|| line.strip_prefix("• "))
763                    .unwrap_or(line);
764                stripped.trim().to_owned()
765            })
766            .collect();
767
768        if entries.is_empty() {
769            return None;
770        }
771
772        Some(self.compact_highlight_entries(&entries))
773    }
774
775    fn compact_highlight_entries(&self, entries: &[String]) -> String {
776        let mut summary =
777            self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
778        if entries.len() > 1 {
779            let remaining = entries.len() - 1;
780            if !summary.is_empty() {
781                let _ = write!(summary, " (+{} more)", remaining);
782            } else {
783                summary = format!("(+{} more)", remaining);
784            }
785        }
786        summary
787    }
788
789    fn truncate_highlight_preview(&self, text: &str) -> String {
790        let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
791        if max == 0 {
792            return String::new();
793        }
794
795        let grapheme_count = text.graphemes(true).count();
796        if grapheme_count <= max {
797            return text.to_owned();
798        }
799
800        // Optimization: return early if string is short (already checked above with grapheme count)
801        // This is safe because count() <= max means it doesn't need truncation.
802
803        let mut truncated = String::new();
804        // pre-allocate capacity
805        truncated.reserve(text.len().min(max * 4));
806        for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
807            truncated.push_str(grapheme);
808        }
809        truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
810        truncated
811    }
812
813    /// Determine if suggestions should be shown in the header
814    fn should_show_suggestions(&self) -> bool {
815        // Show suggestions when input is empty or starts with /
816        self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
817    }
818
819    /// Generate header line with slash command and keyboard shortcut suggestions
820    pub(crate) fn header_suggestions_line(&self) -> Option<Line<'static>> {
821        let dim = self.header_secondary_style().add_modifier(Modifier::DIM);
822        let key = self.header_primary_style().add_modifier(Modifier::BOLD);
823        let label = self.header_secondary_style();
824        let dot = Span::styled(" · ", dim);
825
826        let mut spans = vec![
827            Span::styled("/help", key),
828            dot.clone(),
829            Span::styled("/model", key),
830            dot.clone(),
831            Span::styled("/effort", key),
832            dot.clone(),
833            Span::styled("/config", key),
834            dot.clone(),
835            Span::styled("/clear", key),
836            Span::styled("  │  ", dim),
837            Span::styled("↑↓", key),
838            Span::styled(" nav", label),
839            dot.clone(),
840            Span::styled("Tab", key),
841            Span::styled(" complete", label),
842        ];
843
844        if self.has_delegated_local_agents() {
845            spans.push(Span::styled("  │  ", dim));
846            spans.push(Span::styled("Alt+S", key));
847            spans.push(Span::styled(" agents", label));
848            spans.push(dot.clone());
849            spans.push(Span::styled("Ctrl+B", key));
850            spans.push(Span::styled(" background", label));
851        }
852
853        Some(Line::from(spans))
854    }
855
856    pub(crate) fn section_title_style(&self) -> Style {
857        let mut style = self
858            .styles
859            .default_style()
860            .add_modifier(Modifier::BOLD | Modifier::DIM);
861        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
862            style = style.fg(ratatui_color_from_ansi(primary));
863        }
864        style
865    }
866
867    fn header_primary_style(&self) -> Style {
868        let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
869        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
870            style = style.fg(ratatui_color_from_ansi(primary));
871        }
872        style
873    }
874
875    pub(crate) fn header_secondary_style(&self) -> Style {
876        let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
877        if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
878            style = style.fg(ratatui_color_from_ansi(secondary));
879        }
880        style
881    }
882
883    fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
884        value.strip_prefix(prefix).unwrap_or(value)
885    }
886}