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