Skip to main content

vtcode_tui/core_tui/session/
header.rs

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