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    InlineHeaderContext, InlineHeaderHighlight, InlineHeaderStatusBadge, InlineHeaderStatusTone,
14};
15use super::terminal_capabilities;
16use super::{Session, ratatui_color_from_ansi};
17
18fn clean_reasoning_text(text: &str) -> String {
19    text.lines()
20        .map(str::trim_end)
21        .filter(|line| !line.trim().is_empty())
22        .collect::<Vec<_>>()
23        .join("\n")
24}
25
26fn capitalize_first_letter(s: &str) -> String {
27    let mut chars = s.chars();
28    match chars.next() {
29        None => String::new(),
30        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
31    }
32}
33
34fn compact_tools_format(tools_str: &str) -> String {
35    // Input format: "allow X · prompt Y · deny Z"
36    // Output format: "N" where N is the number of allowed (currently running) tools
37
38    let parts = tools_str.split(" · ");
39    let mut allow_count = 0;
40
41    for part in parts {
42        let trimmed = part.trim();
43        if let Some(num_str) = trimmed.strip_prefix("allow ")
44            && let Ok(num) = num_str.parse::<i32>()
45        {
46            allow_count = num;
47            break; // We found the allow count, no need to continue
48        }
49    }
50
51    allow_count.to_string()
52}
53
54fn compact_context_window_label(context_window_size: usize) -> String {
55    if context_window_size >= 1_000_000 {
56        format!("{}M", context_window_size / 1_000_000)
57    } else if context_window_size >= 1_000 {
58        format!("{}K", context_window_size / 1_000)
59    } else {
60        context_window_size.to_string()
61    }
62}
63
64fn line_is_empty(spans: &[Span<'static>]) -> bool {
65    spans.len() == 1 && spans.first().is_some_and(|span| span.content.is_empty())
66}
67
68fn header_status_badge_style(badge: &InlineHeaderStatusBadge, fallback: Style) -> Style {
69    let color = match badge.tone {
70        InlineHeaderStatusTone::Ready => Color::Green,
71        InlineHeaderStatusTone::Warning => Color::Yellow,
72        InlineHeaderStatusTone::Error => Color::Red,
73    };
74    fallback.fg(color).add_modifier(Modifier::BOLD)
75}
76
77impl Session {
78    pub(crate) fn header_lines(&mut self) -> Vec<Line<'static>> {
79        if let Some(cached) = &self.header_lines_cache {
80            return cached.clone();
81        }
82
83        let lines = vec![self.header_compact_line()];
84        self.header_lines_cache = Some(lines.clone());
85        lines
86    }
87
88    pub(crate) fn header_height_from_lines(&mut self, width: u16, lines: &[Line<'static>]) -> u16 {
89        if width == 0 {
90            return self.header_rows.max(ui::INLINE_HEADER_HEIGHT);
91        }
92
93        if let Some(&height) = self.header_height_cache.get(&width) {
94            return height;
95        }
96
97        let paragraph = self.build_header_paragraph(lines);
98        let measured = paragraph.line_count(width);
99        let resolved = u16::try_from(measured).unwrap_or(u16::MAX);
100        // Limit to max 3 lines to accommodate suggestions
101        let resolved = resolved.clamp(ui::INLINE_HEADER_HEIGHT, 3);
102        self.header_height_cache.insert(width, resolved);
103        resolved
104    }
105
106    pub(crate) fn build_header_paragraph(&self, lines: &[Line<'static>]) -> Paragraph<'static> {
107        let block = Block::bordered()
108            .title(self.header_block_title())
109            .border_type(terminal_capabilities::get_border_type())
110            .style(self.styles.default_style());
111
112        Paragraph::new(lines.to_vec())
113            .style(self.styles.default_style())
114            .wrap(Wrap { trim: true })
115            .block(block)
116    }
117
118    #[cfg(test)]
119    pub(crate) fn header_height_for_width(&mut self, width: u16) -> u16 {
120        let lines = self.header_lines();
121        self.header_height_from_lines(width, &lines)
122    }
123
124    pub fn header_block_title(&self) -> Line<'static> {
125        let fallback = InlineHeaderContext::default();
126        let version = if self.header_context.version.trim().is_empty() {
127            fallback.version
128        } else {
129            self.header_context.version.clone()
130        };
131
132        let app_name = if self.header_context.app_name.trim().is_empty() {
133            ui::HEADER_VERSION_PREFIX
134        } else {
135            self.header_context.app_name.trim()
136        };
137        let prompt = format!("{}{} ", ui::HEADER_VERSION_PROMPT, app_name);
138        let version_text = format!(
139            "{}{}{}",
140            ui::HEADER_VERSION_LEFT_DELIMITER,
141            version.trim(),
142            ui::HEADER_VERSION_RIGHT_DELIMITER
143        );
144
145        let prompt_style = self.section_title_style();
146        let version_style = self.header_secondary_style().add_modifier(Modifier::DIM);
147
148        Line::from(vec![
149            Span::styled(prompt, prompt_style),
150            Span::styled(version_text, version_style),
151        ])
152    }
153
154    pub fn header_title_line(&self) -> Line<'static> {
155        // First line: badge-style provider + model + reasoning summary
156        let mut spans = Vec::new();
157
158        let provider = self.header_provider_short_value();
159        let model = self.header_model_short_value();
160        let reasoning = self.header_reasoning_short_value();
161
162        if !provider.is_empty() {
163            let capitalized_provider = capitalize_first_letter(&provider);
164            let mut style = self.header_primary_style();
165            style = style.add_modifier(Modifier::BOLD);
166            spans.push(Span::styled(capitalized_provider, style));
167        }
168
169        if !model.is_empty() {
170            if !spans.is_empty() {
171                spans.push(Span::raw(" "));
172            }
173            let mut style = self.header_primary_style();
174            style = style.add_modifier(Modifier::ITALIC);
175            spans.push(Span::styled(model, style));
176        }
177
178        if !reasoning.is_empty() {
179            if !spans.is_empty() {
180                spans.push(Span::raw(" "));
181            }
182            let mut style = self.header_secondary_style();
183            style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
184
185            if let Some(stage) = &self.header_context.reasoning_stage {
186                let mut stage_style = style;
187                stage_style = stage_style
188                    .remove_modifier(Modifier::DIM)
189                    .add_modifier(Modifier::BOLD);
190                spans.push(Span::styled(format!("[{}]", stage), stage_style));
191                spans.push(Span::raw(" "));
192            }
193
194            spans.push(Span::styled(reasoning.to_string(), style));
195        }
196
197        if spans.is_empty() {
198            spans.push(Span::raw(String::new()));
199        }
200
201        Line::from(spans)
202    }
203
204    fn header_compact_line(&self) -> Line<'static> {
205        let mut spans = self.header_title_line().spans;
206        if line_is_empty(&spans) {
207            spans.clear();
208        }
209
210        let mut meta_spans = self.header_meta_line().spans;
211        if line_is_empty(&meta_spans) {
212            meta_spans.clear();
213        }
214
215        let mut tail_spans = if self.should_show_suggestions() {
216            self.header_suggestions_line()
217        } else {
218            self.header_highlights_line()
219        }
220        .map(|line| line.spans)
221        .unwrap_or_default();
222
223        if line_is_empty(&tail_spans) {
224            tail_spans.clear();
225        }
226
227        let separator_style = self.header_secondary_style();
228        let separator = Span::styled(
229            ui::HEADER_MODE_PRIMARY_SEPARATOR.to_owned(),
230            separator_style,
231        );
232
233        let mut append_section = |section: &mut Vec<Span<'static>>| {
234            if section.is_empty() {
235                return;
236            }
237            if !spans.is_empty() {
238                spans.push(separator.clone());
239            }
240            spans.append(section);
241        };
242
243        append_section(&mut meta_spans);
244        append_section(&mut tail_spans);
245
246        if spans.is_empty() {
247            spans.push(Span::raw(String::new()));
248        }
249
250        Line::from(spans)
251    }
252
253    fn header_provider_value(&self) -> String {
254        let trimmed = self.header_context.provider.trim();
255        if trimmed.is_empty() {
256            InlineHeaderContext::default().provider
257        } else {
258            self.header_context.provider.clone()
259        }
260    }
261
262    fn header_model_value(&self) -> String {
263        let trimmed = self.header_context.model.trim();
264        if trimmed.is_empty() {
265            InlineHeaderContext::default().model
266        } else {
267            self.header_context.model.clone()
268        }
269    }
270
271    fn header_mode_label(&self) -> String {
272        let trimmed = self.header_context.mode.trim();
273        if trimmed.is_empty() {
274            InlineHeaderContext::default().mode
275        } else {
276            self.header_context.mode.clone()
277        }
278    }
279
280    pub fn header_mode_short_label(&self) -> String {
281        let full = self.header_mode_label();
282        let value = full.trim();
283        if value.eq_ignore_ascii_case(ui::HEADER_MODE_AUTO) {
284            return "Auto".to_owned();
285        }
286        if value.eq_ignore_ascii_case(ui::HEADER_MODE_INLINE) {
287            return "Inline".to_owned();
288        }
289        if value.eq_ignore_ascii_case(ui::HEADER_MODE_ALTERNATE) {
290            return "Alternate".to_owned();
291        }
292
293        // Handle common abbreviations with more descriptive names
294        if value.to_lowercase() == "std" {
295            return "Session: Standard".to_owned();
296        }
297
298        let compact = value
299            .strip_suffix(ui::HEADER_MODE_FULL_AUTO_SUFFIX)
300            .unwrap_or(value)
301            .trim();
302        compact.to_owned()
303    }
304
305    fn header_reasoning_value(&self) -> Option<String> {
306        let raw_reasoning = &self.header_context.reasoning;
307        let cleaned = clean_reasoning_text(raw_reasoning);
308        let trimmed = cleaned.trim();
309        let value = if trimmed.is_empty() {
310            InlineHeaderContext::default().reasoning
311        } else {
312            cleaned
313        };
314        if value.trim().is_empty() {
315            None
316        } else {
317            Some(value)
318        }
319    }
320
321    pub fn header_provider_short_value(&self) -> String {
322        let value = self.header_provider_value();
323        Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX)
324            .trim()
325            .to_owned()
326    }
327
328    pub fn header_model_short_value(&self) -> String {
329        let value = self.header_model_value();
330        let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX)
331            .trim()
332            .to_owned();
333
334        match self.header_context.context_window_size {
335            Some(context_window_size) if context_window_size > 0 => {
336                format!(
337                    "{} ({})",
338                    model,
339                    compact_context_window_label(context_window_size)
340                )
341            }
342            _ => model,
343        }
344    }
345
346    pub fn header_reasoning_short_value(&self) -> String {
347        let value = self.header_reasoning_value().unwrap_or_default();
348        Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX)
349            .trim()
350            .to_owned()
351    }
352
353    pub fn header_chain_values(&self) -> Vec<String> {
354        let defaults = InlineHeaderContext::default();
355        let mut values = Vec::new();
356
357        for (value, fallback) in [
358            (&self.header_context.tools, defaults.tools),
359            (&self.header_context.git, defaults.git),
360        ] {
361            let selected = if value.trim().is_empty() {
362                fallback
363            } else {
364                value.clone()
365            };
366            let trimmed = selected.trim();
367            if trimmed.is_empty() {
368                continue;
369            }
370
371            if let Some(body) = trimmed.strip_prefix(ui::HEADER_TOOLS_PREFIX) {
372                let compact_tools = compact_tools_format(body.trim());
373                values.push(format!("Tools: {}", compact_tools));
374                continue;
375            }
376
377            if let Some(body) = trimmed.strip_prefix(ui::HEADER_GIT_PREFIX) {
378                let body = body.trim();
379                if !body.is_empty() {
380                    values.push(body.to_owned());
381                }
382                continue;
383            }
384
385            values.push(selected);
386        }
387
388        values
389    }
390
391    pub fn header_meta_line(&self) -> Line<'static> {
392        use super::super::types::EditingMode;
393
394        let mut spans = Vec::new();
395
396        let mut first_section = true;
397        let separator_style = self.header_secondary_style();
398
399        let push_badge =
400            |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
401                if !*first {
402                    spans.push(Span::styled(
403                        ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
404                        separator_style,
405                    ));
406                }
407                spans.push(Span::styled(text, style));
408                *first = false;
409            };
410
411        // Show editing mode badge with color coding
412        if self.header_context.editing_mode == EditingMode::Plan {
413            let badge_style = Style::default()
414                .fg(Color::Yellow)
415                .add_modifier(Modifier::BOLD);
416            push_badge(
417                &mut spans,
418                "Plan mode on".to_string(),
419                badge_style,
420                &mut first_section,
421            );
422        }
423
424        // Show autonomous mode indicator
425        if self.header_context.autonomous_mode {
426            let badge_style = Style::default()
427                .fg(Color::Green)
428                .add_modifier(Modifier::BOLD);
429            push_badge(
430                &mut spans,
431                "[AUTO]".to_string(),
432                badge_style,
433                &mut first_section,
434            );
435        }
436
437        // Show trust level badge with color coding
438        let trust_value = self.header_context.workspace_trust.to_lowercase();
439        if trust_value.contains("full auto") || trust_value.contains("full_auto") {
440            let badge_style = Style::default()
441                .fg(Color::Cyan)
442                .add_modifier(Modifier::BOLD);
443            push_badge(
444                &mut spans,
445                "Accept edits".to_string(),
446                badge_style,
447                &mut first_section,
448            );
449        } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
450            let badge_style = Style::default()
451                .fg(Color::Green)
452                .add_modifier(Modifier::BOLD);
453            push_badge(
454                &mut spans,
455                "[SAFE]".to_string(),
456                badge_style,
457                &mut first_section,
458            );
459        }
460
461        if let Some(badge) = self
462            .header_context
463            .search_tools
464            .as_ref()
465            .filter(|badge| !badge.text.trim().is_empty())
466        {
467            if !first_section {
468                spans.push(Span::styled(
469                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
470                    self.header_secondary_style(),
471                ));
472            }
473            let style = header_status_badge_style(badge, self.header_primary_style());
474            spans.push(Span::styled(badge.text.clone(), style));
475            first_section = false;
476        }
477
478        if let Some(badge) = self
479            .header_context
480            .pr_review
481            .as_ref()
482            .filter(|badge| !badge.text.trim().is_empty())
483        {
484            if !first_section {
485                spans.push(Span::styled(
486                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
487                    self.header_secondary_style(),
488                ));
489            }
490            let style = header_status_badge_style(badge, self.header_primary_style());
491            spans.push(Span::styled(badge.text.clone(), style));
492            first_section = false;
493        }
494
495        for value in self.header_chain_values() {
496            if !first_section {
497                spans.push(Span::styled(
498                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
499                    self.header_secondary_style(),
500                ));
501            }
502            spans.push(Span::styled(value, self.header_primary_style()));
503            first_section = false;
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    fn header_suggestions_line(&self) -> Option<Line<'static>> {
627        let spans = vec![
628            Span::styled(
629                "/help",
630                self.header_primary_style().add_modifier(Modifier::BOLD),
631            ),
632            Span::styled(
633                " · ",
634                self.header_secondary_style().add_modifier(Modifier::DIM),
635            ),
636            Span::styled(
637                "/model",
638                self.header_primary_style().add_modifier(Modifier::BOLD),
639            ),
640            Span::styled(
641                "  |  ",
642                self.header_secondary_style().add_modifier(Modifier::DIM),
643            ),
644            Span::styled(
645                "↑↓",
646                self.header_primary_style().add_modifier(Modifier::BOLD),
647            ),
648            Span::styled(" Nav · ", self.header_secondary_style()),
649            Span::styled(
650                "Tab",
651                self.header_primary_style().add_modifier(Modifier::BOLD),
652            ),
653            Span::styled(" Complete", self.header_secondary_style()),
654        ];
655
656        Some(Line::from(spans))
657    }
658
659    pub(crate) fn section_title_style(&self) -> Style {
660        let mut style = self.styles.default_style().add_modifier(Modifier::BOLD);
661        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
662            style = style.fg(ratatui_color_from_ansi(primary));
663        }
664        style
665    }
666
667    fn header_primary_style(&self) -> Style {
668        let mut style = self.styles.default_style();
669        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
670            style = style.fg(ratatui_color_from_ansi(primary));
671        }
672        style
673    }
674
675    pub(crate) fn header_secondary_style(&self) -> Style {
676        let mut style = self.styles.default_style();
677        if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
678            style = style.fg(ratatui_color_from_ansi(secondary));
679        }
680        style
681    }
682
683    fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
684        value.strip_prefix(prefix).unwrap_or(value)
685    }
686}