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        if let Some(editor_context) = self
349            .header_context
350            .editor_context
351            .as_ref()
352            .filter(|value| !value.trim().is_empty())
353        {
354            values.push(editor_context.clone());
355        }
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        // Show editing mode badge with color coding
397        match self.header_context.editing_mode {
398            EditingMode::Plan => {
399                // Yellow badge for Plan mode (read-only)
400                let badge_style = Style::default()
401                    .fg(Color::Yellow)
402                    .add_modifier(Modifier::BOLD);
403                spans.push(Span::styled("Plan mode on".to_string(), badge_style));
404                spans.push(Span::styled(
405                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
406                    self.header_secondary_style(),
407                ));
408            }
409            EditingMode::Edit => {
410                // No badge for Edit mode (default)
411            }
412        }
413
414        // Show autonomous mode indicator
415        if self.header_context.autonomous_mode {
416            let badge_style = Style::default()
417                .fg(Color::Green)
418                .add_modifier(Modifier::BOLD);
419            spans.push(Span::styled("[AUTO]".to_string(), badge_style));
420            spans.push(Span::styled(
421                ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
422                self.header_secondary_style(),
423            ));
424        }
425
426        // Show trust level badge with color coding
427        let trust_value = self.header_context.workspace_trust.to_lowercase();
428        if trust_value.contains("full auto") || trust_value.contains("full_auto") {
429            // Cyan badge for full auto trust
430            let badge_style = Style::default()
431                .fg(Color::Cyan)
432                .add_modifier(Modifier::BOLD);
433            spans.push(Span::styled("Accept edits".to_string(), badge_style));
434            spans.push(Span::styled(
435                ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
436                self.header_secondary_style(),
437            ));
438        } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
439            // Green badge for tools policy (safeguarded)
440            let badge_style = Style::default()
441                .fg(Color::Green)
442                .add_modifier(Modifier::BOLD);
443            spans.push(Span::styled("[SAFE]".to_string(), badge_style));
444            spans.push(Span::styled(
445                ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
446                self.header_secondary_style(),
447            ));
448        }
449
450        let mut first_section = spans.is_empty();
451        let mode_label = self.header_mode_short_label();
452        if !mode_label.trim().is_empty() {
453            let mut mode_style = self.header_primary_style().add_modifier(Modifier::BOLD);
454            if self.header_context.editing_mode == EditingMode::Plan {
455                mode_style = mode_style.fg(Color::Yellow);
456            } else if self.header_context.autonomous_mode {
457                mode_style = mode_style.fg(Color::Green);
458            }
459            spans.push(Span::styled(mode_label, mode_style));
460            first_section = false;
461        }
462
463        if let Some(badge) = self
464            .header_context
465            .search_tools
466            .as_ref()
467            .filter(|badge| !badge.text.trim().is_empty())
468        {
469            if !first_section {
470                spans.push(Span::styled(
471                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
472                    self.header_secondary_style(),
473                ));
474            }
475            let style = header_status_badge_style(badge, self.header_primary_style());
476            spans.push(Span::styled(badge.text.clone(), style));
477            first_section = false;
478        }
479
480        for value in self.header_chain_values() {
481            if !first_section {
482                spans.push(Span::styled(
483                    ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
484                    self.header_secondary_style(),
485                ));
486            }
487            spans.push(Span::styled(value, self.header_primary_style()));
488            first_section = false;
489        }
490
491        if spans.is_empty() {
492            spans.push(Span::raw(String::new()));
493        }
494
495        Line::from(spans)
496    }
497
498    fn header_highlights_line(&self) -> Option<Line<'static>> {
499        let mut spans = Vec::new();
500        let mut first_section = true;
501
502        for highlight in &self.header_context.highlights {
503            let title = highlight.title.trim();
504            let summary = self.header_highlight_summary(highlight);
505
506            if title.is_empty() && summary.is_none() {
507                continue;
508            }
509
510            if !first_section {
511                spans.push(Span::styled(
512                    ui::HEADER_META_SEPARATOR.to_owned(),
513                    self.header_secondary_style(),
514                ));
515            }
516
517            if !title.is_empty() {
518                let mut title_style = self.header_secondary_style();
519                title_style = title_style.add_modifier(Modifier::BOLD);
520                let mut title_text = title.to_owned();
521                if summary.is_some() {
522                    title_text.push(':');
523                }
524                spans.push(Span::styled(title_text, title_style));
525                if summary.is_some() {
526                    spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
527                }
528            }
529
530            if let Some(body) = summary {
531                spans.push(Span::styled(body, self.header_primary_style()));
532            }
533
534            first_section = false;
535        }
536
537        if spans.is_empty() {
538            None
539        } else {
540            Some(Line::from(spans))
541        }
542    }
543
544    fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
545        let entries: Vec<String> = highlight
546            .lines
547            .iter()
548            .map(|line| line.trim())
549            .filter(|line| !line.is_empty())
550            .map(|line| {
551                let stripped = line
552                    .strip_prefix("- ")
553                    .or_else(|| line.strip_prefix("• "))
554                    .unwrap_or(line);
555                stripped.trim().to_owned()
556            })
557            .collect();
558
559        if entries.is_empty() {
560            return None;
561        }
562
563        Some(self.compact_highlight_entries(&entries))
564    }
565
566    fn compact_highlight_entries(&self, entries: &[String]) -> String {
567        let mut summary =
568            self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
569        if entries.len() > 1 {
570            let remaining = entries.len() - 1;
571            if !summary.is_empty() {
572                let _ = write!(summary, " (+{} more)", remaining);
573            } else {
574                summary = format!("(+{} more)", remaining);
575            }
576        }
577        summary
578    }
579
580    fn truncate_highlight_preview(&self, text: &str) -> String {
581        let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
582        if max == 0 {
583            return String::new();
584        }
585
586        let grapheme_count = text.graphemes(true).count();
587        if grapheme_count <= max {
588            return text.to_owned();
589        }
590
591        // Optimization: return early if string is short (already checked above with grapheme count)
592        // This is safe because count() <= max means it doesn't need truncation.
593
594        let mut truncated = String::new();
595        // pre-allocate capacity
596        truncated.reserve(text.len().min(max * 4));
597        for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
598            truncated.push_str(grapheme);
599        }
600        truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
601        truncated
602    }
603
604    /// Determine if suggestions should be shown in the header
605    fn should_show_suggestions(&self) -> bool {
606        // Show suggestions when input is empty or starts with /
607        self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
608    }
609
610    /// Generate header line with slash command and keyboard shortcut suggestions
611    fn header_suggestions_line(&self) -> Option<Line<'static>> {
612        let spans = vec![
613            Span::styled(
614                "/help",
615                self.header_primary_style().add_modifier(Modifier::BOLD),
616            ),
617            Span::styled(
618                " · ",
619                self.header_secondary_style().add_modifier(Modifier::DIM),
620            ),
621            Span::styled(
622                "/model",
623                self.header_primary_style().add_modifier(Modifier::BOLD),
624            ),
625            Span::styled(
626                "  |  ",
627                self.header_secondary_style().add_modifier(Modifier::DIM),
628            ),
629            Span::styled(
630                "↑↓",
631                self.header_primary_style().add_modifier(Modifier::BOLD),
632            ),
633            Span::styled(" Nav · ", self.header_secondary_style()),
634            Span::styled(
635                "Tab",
636                self.header_primary_style().add_modifier(Modifier::BOLD),
637            ),
638            Span::styled(" Complete", self.header_secondary_style()),
639        ];
640
641        Some(Line::from(spans))
642    }
643
644    pub(super) fn section_title_style(&self) -> Style {
645        let mut style = self.styles.default_style().add_modifier(Modifier::BOLD);
646        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
647            style = style.fg(ratatui_color_from_ansi(primary));
648        }
649        style
650    }
651
652    fn header_primary_style(&self) -> Style {
653        let mut style = self.styles.default_style();
654        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
655            style = style.fg(ratatui_color_from_ansi(primary));
656        }
657        style
658    }
659
660    pub(super) fn header_secondary_style(&self) -> Style {
661        let mut style = self.styles.default_style();
662        if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
663            style = style.fg(ratatui_color_from_ansi(secondary));
664        }
665        style
666    }
667
668    fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
669        value.strip_prefix(prefix).unwrap_or(value)
670    }
671}