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