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