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