vtcode_ui/tui/core_tui/session/
header.rs1use std::fmt::Write;
2
3use ratatui::{
4 style::{Color, Modifier, Style},
5 text::{Line, Span},
6 widgets::{Block, Paragraph, Wrap},
7};
8use unicode_segmentation::UnicodeSegmentation;
9
10use crate::tui::config::constants::ui;
11
12use super::super::types::{
13 InlineHeaderBadge, InlineHeaderContext, InlineHeaderHighlight, InlineHeaderStatusBadge,
14 InlineHeaderStatusTone,
15};
16use super::terminal_capabilities;
17use super::utils::line_truncation::truncate_line_with_ellipsis_if_overflow;
18use super::{Session, ratatui_color_from_ansi, ratatui_style_from_inline};
19
20fn clean_reasoning_text(text: &str) -> String {
21 vtcode_commons::formatting::clean_reasoning_text(text)
22}
23
24fn capitalize_first_letter(s: &str) -> String {
25 let mut chars = s.chars();
26 match chars.next() {
27 None => String::new(),
28 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
29 }
30}
31
32fn format_model_summary_label(model: &str) -> String {
33 model
34 .split('-')
35 .map(capitalize_first_letter)
36 .collect::<Vec<_>>()
37 .join("-")
38}
39
40fn primary_agent_header_label(name: Option<&str>) -> String {
41 let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
42 return "Duck".to_string();
43 };
44 format_model_summary_label(name)
45}
46
47fn compact_context_window_label(context_window_size: usize) -> String {
48 if context_window_size >= 1_000_000 {
49 format!("{}M", context_window_size / 1_000_000)
50 } else if context_window_size >= 1_000 {
51 format!("{}K", context_window_size / 1_000)
52 } else {
53 context_window_size.to_string()
54 }
55}
56
57fn line_is_empty(spans: &[Span<'static>]) -> bool {
58 spans.len() == 1 && spans.first().is_some_and(|span| span.content.is_empty())
59}
60
61fn header_status_badge_style(badge: &InlineHeaderStatusBadge, fallback: Style) -> Style {
62 let color = match badge.tone {
63 InlineHeaderStatusTone::Ready => Color::Green,
64 InlineHeaderStatusTone::Warning => Color::Yellow,
65 InlineHeaderStatusTone::Error => Color::Red,
66 };
67 fallback.fg(color).add_modifier(Modifier::BOLD)
68}
69
70fn header_context_badge_style(badge: &InlineHeaderBadge) -> Style {
71 let mut style = ratatui_style_from_inline(&badge.style, None);
72 if badge.full_background {
73 style = style.add_modifier(Modifier::BOLD);
74 }
75 style
76}
77
78impl Session {
79 pub(crate) fn header_lines(&mut self) -> Vec<Line<'static>> {
80 if let Some(cached) = &self.header_lines_cache {
81 return cached.clone();
82 }
83
84 let lines = if self.appearance.hide_header {
85 vec![self.header_block_title_for_width(self.transcript_width)]
86 } else {
87 vec![self.header_compact_line()]
88 };
89 self.header_lines_cache = Some(lines.clone());
90 lines
91 }
92
93 pub(crate) fn header_height_from_lines(&mut self, width: u16, lines: &[Line<'static>]) -> u16 {
94 if self.appearance.hide_header {
95 return 1;
96 }
97
98 if width == 0 {
99 return self.header_rows.max(ui::INLINE_HEADER_HEIGHT);
100 }
101
102 if let Some(&height) = self.header_height_cache.get(&width) {
103 return height;
104 }
105
106 let paragraph = self.build_header_paragraph(lines);
107 let measured = paragraph.line_count(width);
108 let resolved = u16::try_from(measured).unwrap_or(u16::MAX);
109 let resolved = resolved.clamp(ui::INLINE_HEADER_HEIGHT, 3);
111 self.header_height_cache.insert(width, resolved);
112 resolved
113 }
114
115 pub(crate) fn build_header_paragraph(&self, lines: &[Line<'static>]) -> Paragraph<'static> {
116 let text_style = self.header_primary_style().add_modifier(Modifier::DIM);
117
118 if self.appearance.hide_header {
119 return Paragraph::new(lines.to_vec())
120 .style(text_style)
121 .wrap(Wrap { trim: true });
122 }
123
124 let mut border_style = Style::default();
125 if let Some(accent) = self
126 .theme
127 .tool_accent
128 .or(self.theme.primary)
129 .or(self.theme.foreground)
130 {
131 border_style = border_style.fg(ratatui_color_from_ansi(accent));
132 }
133 let text_style = self.header_primary_style().add_modifier(Modifier::DIM);
134 let block = Block::bordered()
135 .title(self.header_block_title())
136 .border_type(terminal_capabilities::get_border_type())
137 .border_style(border_style)
138 .style(self.styles.default_style());
139
140 Paragraph::new(lines.to_vec())
141 .style(text_style)
142 .wrap(Wrap { trim: true })
143 .block(block)
144 }
145
146 #[cfg(test)]
147 pub(crate) fn header_height_for_width(&mut self, width: u16) -> u16 {
148 let lines = self.header_lines();
149 self.header_height_from_lines(width, &lines)
150 }
151
152 pub fn header_block_title(&self) -> Line<'static> {
153 self.header_block_title_for_width(0)
154 }
155
156 fn header_block_title_for_width(&self, width: u16) -> Line<'static> {
157 let fallback = InlineHeaderContext::default();
158 let version = if self.header_context.version.trim().is_empty() {
159 fallback.version
160 } else {
161 self.header_context.version.clone()
162 };
163
164 let app_name = if self.header_context.app_name.trim().is_empty() {
165 ui::HEADER_VERSION_PREFIX
166 } else {
167 self.header_context.app_name.trim()
168 };
169 let prompt = format!("{}{}", ui::HEADER_VERSION_PROMPT, app_name);
170 let version_text = format!(
171 " {}{}{}",
172 ui::HEADER_VERSION_LEFT_DELIMITER,
173 version.trim(),
174 ui::HEADER_VERSION_RIGHT_DELIMITER
175 );
176
177 let prompt_style = self.section_title_style();
178 let version_style = self.header_secondary_style().add_modifier(Modifier::DIM);
179
180 let mut spans = vec![
181 Span::styled(prompt, prompt_style),
182 Span::styled(version_text, version_style),
183 ];
184
185 if self.appearance.hide_header && width > 0 {
186 let right_spans = self.header_compact_right_spans();
187 if right_spans.is_empty() {
188 return Line::from(spans);
189 }
190
191 let left_width = spans.iter().map(Span::width).sum::<usize>();
192 let summary_width = right_spans.iter().map(Span::width).sum::<usize>();
193 let available_width = usize::from(width);
194 let spacer_width = available_width
195 .saturating_sub(left_width.saturating_add(summary_width))
196 .max(1);
197 spans.push(Span::raw(" ".repeat(spacer_width)));
198 spans.extend(right_spans);
199 }
200
201 let line = Line::from(spans);
202 if width > 0 {
203 truncate_line_with_ellipsis_if_overflow(line, usize::from(width))
204 } else {
205 line
206 }
207 }
208
209 fn header_compact_right_spans(&self) -> Vec<Span<'static>> {
210 let mut spans = Vec::new();
211 let agent_label = primary_agent_header_label(self.header_context.primary_agent.as_deref());
212 let model_summary_spans = self.header_compact_model_summary_spans();
213 if !agent_label.trim().is_empty() {
214 spans.push(Span::styled(
215 agent_label,
216 Style::default()
217 .fg(Color::Magenta)
218 .add_modifier(Modifier::BOLD),
219 ));
220 }
221
222 if !spans.is_empty() && !model_summary_spans.is_empty() {
223 spans.push(Span::styled(
224 " · ".to_owned(),
225 self.header_secondary_style(),
226 ));
227 }
228 spans.extend(model_summary_spans);
229
230 spans
231 }
232
233 fn header_compact_model_summary_spans(&self) -> Vec<Span<'static>> {
234 let provider = self.header_provider_short_value();
235 let model = self.header_model_short_value();
236 let reasoning = self.header_reasoning_short_value();
237 let mut spans = Vec::new();
238 let mut provider_model_parts = Vec::new();
239
240 match (
241 !provider.is_empty() && !provider.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
242 !model.is_empty() && !model.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
243 ) {
244 (true, true) => provider_model_parts.push(format!(
245 "{} {}",
246 capitalize_first_letter(&provider),
247 format_model_summary_label(&model)
248 )),
249 (true, false) => provider_model_parts.push(capitalize_first_letter(&provider)),
250 (false, true) => provider_model_parts.push(format_model_summary_label(&model)),
251 (false, false) => {}
252 }
253
254 if let Some(summary) =
255 (!provider_model_parts.is_empty()).then(|| provider_model_parts.join(" "))
256 {
257 spans.push(Span::styled(
258 summary,
259 self.header_secondary_style().add_modifier(Modifier::DIM),
260 ));
261 }
262
263 if !reasoning.is_empty() && !reasoning.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER)
264 {
265 if !spans.is_empty() {
266 spans.push(Span::styled(
267 " · ".to_owned(),
268 self.header_secondary_style(),
269 ));
270 }
271 let label_style = self
272 .header_secondary_style()
273 .add_modifier(Modifier::ITALIC | Modifier::DIM);
274 let value_style = self.header_secondary_style().add_modifier(Modifier::ITALIC);
275 spans.push(Span::styled("effort:".to_owned(), label_style));
276 spans.push(Span::styled(" ".to_owned(), label_style));
277 spans.push(Span::styled(reasoning, value_style));
278 }
279
280 spans
281 }
282
283 pub fn header_title_line(&self) -> Line<'static> {
284 let mut spans = Vec::new();
286
287 let provider = self.header_provider_short_value();
288 let model = self.header_model_short_value();
289 let reasoning = self.header_reasoning_short_value();
290
291 if !provider.is_empty() {
292 let capitalized_provider = capitalize_first_letter(&provider);
293 let mut style = self.header_primary_style();
294 style = style.add_modifier(Modifier::BOLD);
295 spans.push(Span::styled(capitalized_provider, style));
296 }
297
298 if !model.is_empty() {
299 if !spans.is_empty() {
300 spans.push(Span::raw(" "));
301 }
302 let mut style = self.header_primary_style();
303 style = style.add_modifier(Modifier::ITALIC);
304 spans.push(Span::styled(model, style));
305 }
306
307 if !reasoning.is_empty() {
308 if !spans.is_empty() {
309 spans.push(Span::raw(" "));
310 }
311 let mut style = self.header_secondary_style();
312 style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
313
314 if let Some(stage) = &self.header_context.reasoning_stage {
315 let mut stage_style = style;
316 stage_style = stage_style
317 .remove_modifier(Modifier::DIM)
318 .add_modifier(Modifier::BOLD);
319 spans.push(Span::styled(format!("[{}]", stage), stage_style));
320 spans.push(Span::raw(" "));
321 }
322
323 spans.push(Span::styled(reasoning.to_string(), style));
324 }
325
326 if spans.is_empty() {
327 spans.push(Span::raw(String::new()));
328 }
329
330 Line::from(spans)
331 }
332
333 fn header_compact_line(&self) -> Line<'static> {
334 let mut spans = self.header_title_line().spans;
335 if line_is_empty(&spans) {
336 spans.clear();
337 }
338
339 let mut meta_spans = self.header_meta_line().spans;
340 if line_is_empty(&meta_spans) {
341 meta_spans.clear();
342 }
343
344 let mut tail_spans = if self.should_show_suggestions() {
345 self.header_suggestions_line()
346 } else {
347 self.header_highlights_line()
348 }
349 .map(|line| line.spans)
350 .unwrap_or_default();
351
352 if line_is_empty(&tail_spans) {
353 tail_spans.clear();
354 }
355
356 let separator_style = self.header_secondary_style();
357 let separator = Span::styled(ui::HEADER_PRIMARY_SEPARATOR.to_owned(), separator_style);
358
359 let mut append_section = |section: &mut Vec<Span<'static>>| {
360 if section.is_empty() {
361 return;
362 }
363 if !spans.is_empty() {
364 spans.push(separator.clone());
365 }
366 spans.append(section);
367 };
368
369 append_section(&mut meta_spans);
370 append_section(&mut tail_spans);
371
372 if spans.is_empty() {
373 spans.push(Span::raw(String::new()));
374 }
375
376 Line::from(spans)
377 }
378
379 fn header_provider_value(&self) -> String {
380 let trimmed = self.header_context.provider.trim();
381 if trimmed.is_empty() {
382 InlineHeaderContext::default().provider
383 } else {
384 self.header_context.provider.clone()
385 }
386 }
387
388 fn header_model_value(&self) -> String {
389 let trimmed = self.header_context.model.trim();
390 if trimmed.is_empty() {
391 InlineHeaderContext::default().model
392 } else {
393 self.header_context.model.clone()
394 }
395 }
396
397 fn header_reasoning_value(&self) -> Option<String> {
398 let raw_reasoning = &self.header_context.reasoning;
399 let cleaned = clean_reasoning_text(raw_reasoning);
400 let trimmed = cleaned.trim();
401 let value = if trimmed.is_empty() {
402 InlineHeaderContext::default().reasoning
403 } else {
404 cleaned
405 };
406 if value.trim().is_empty() {
407 None
408 } else {
409 Some(value)
410 }
411 }
412
413 pub fn header_provider_short_value(&self) -> String {
414 let value = self.header_provider_value();
415 Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX)
416 .trim()
417 .to_owned()
418 }
419
420 pub fn header_model_short_value(&self) -> String {
421 let value = self.header_model_value();
422 let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX)
423 .trim()
424 .to_owned();
425
426 match self.header_context.context_window_size {
427 Some(context_window_size) if context_window_size > 0 => {
428 format!(
429 "{} ({})",
430 model,
431 compact_context_window_label(context_window_size)
432 )
433 }
434 _ => model,
435 }
436 }
437
438 pub fn header_reasoning_short_value(&self) -> String {
439 let value = self.header_reasoning_value().unwrap_or_default();
440 Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX)
441 .trim()
442 .to_owned()
443 }
444
445 pub fn header_chain_values(&self) -> Vec<String> {
446 let mut values = Vec::new();
447
448 for value in [
449 &self.header_context.tools,
450 &self.header_context.git,
451 &self.header_context.mcp,
452 ] {
453 let trimmed = value.trim();
454 if trimmed.is_empty() {
455 continue;
456 }
457
458 if trimmed.starts_with(ui::HEADER_TOOLS_PREFIX)
459 || trimmed.starts_with(ui::HEADER_GIT_PREFIX)
460 {
461 continue;
462 }
463
464 if let Some(body) = trimmed.strip_prefix(ui::HEADER_MCP_PREFIX) {
465 let body = body.trim();
466 if body.is_empty() || body.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER) {
467 continue;
468 }
469 values.push(format!("MCP: {}", body));
470 continue;
471 }
472
473 values.push(trimmed.to_owned());
474 }
475
476 values
477 }
478
479 pub fn header_meta_line(&self) -> Line<'static> {
480 let mut spans = Vec::new();
481
482 let mut first_section = true;
483 let separator_style = self.header_secondary_style();
484
485 let push_badge =
486 |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
487 if !*first {
488 spans.push(Span::styled(
489 ui::HEADER_SECONDARY_SEPARATOR.to_owned(),
490 separator_style,
491 ));
492 }
493 spans.push(Span::styled(text, style));
494 *first = false;
495 };
496
497 let agent_style = Style::default()
498 .fg(Color::Magenta)
499 .add_modifier(Modifier::BOLD);
500 push_badge(
501 &mut spans,
502 primary_agent_header_label(self.header_context.primary_agent.as_deref()),
503 agent_style,
504 &mut first_section,
505 );
506
507 let trust_value = self.header_context.workspace_trust.to_lowercase();
509 if trust_value.contains("full auto") || trust_value.contains("full_auto") {
510 let badge_style = Style::default()
511 .fg(Color::Cyan)
512 .add_modifier(Modifier::BOLD);
513 push_badge(
514 &mut spans,
515 "Full-auto".to_string(),
516 badge_style,
517 &mut first_section,
518 );
519 } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
520 let badge_style = Style::default()
521 .fg(Color::Green)
522 .add_modifier(Modifier::BOLD);
523 push_badge(
524 &mut spans,
525 "Safe".to_string(),
526 badge_style,
527 &mut first_section,
528 );
529 }
530
531 if let Some(badge) = self
532 .header_context
533 .persistent_memory
534 .as_ref()
535 .filter(|badge| !badge.text.trim().is_empty())
536 {
537 if !first_section {
538 spans.push(Span::styled(
539 ui::HEADER_SECONDARY_SEPARATOR.to_owned(),
540 self.header_secondary_style(),
541 ));
542 }
543 let style = header_status_badge_style(badge, self.header_primary_style());
544 spans.push(Span::styled(badge.text.clone(), style));
545 first_section = false;
546 }
547
548 if let Some(badge) = self
549 .header_context
550 .pr_review
551 .as_ref()
552 .filter(|badge| !badge.text.trim().is_empty())
553 {
554 if !first_section {
555 spans.push(Span::styled(
556 ui::HEADER_SECONDARY_SEPARATOR.to_owned(),
557 self.header_secondary_style(),
558 ));
559 }
560 let style = header_status_badge_style(badge, self.header_primary_style());
561 spans.push(Span::styled(badge.text.clone(), style));
562 first_section = false;
563 }
564
565 for badge in self
566 .header_context
567 .subagent_badges
568 .iter()
569 .filter(|badge| !badge.text.trim().is_empty())
570 {
571 if !first_section {
572 spans.push(Span::styled(
573 ui::HEADER_SECONDARY_SEPARATOR.to_owned(),
574 self.header_secondary_style(),
575 ));
576 }
577 let text = if badge.full_background {
578 format!(" {} ", badge.text)
579 } else {
580 badge.text.clone()
581 };
582 spans.push(Span::styled(text, header_context_badge_style(badge)));
583 first_section = false;
584 }
585
586 for value in self.header_chain_values() {
587 if !first_section {
588 spans.push(Span::styled(
589 ui::HEADER_SECONDARY_SEPARATOR.to_owned(),
590 self.header_secondary_style(),
591 ));
592 }
593 spans.push(Span::styled(value, self.header_primary_style()));
594 first_section = false;
595 }
596
597 if spans.is_empty() {
598 spans.push(Span::raw(String::new()));
599 }
600
601 Line::from(spans)
602 }
603
604 fn header_highlights_line(&self) -> Option<Line<'static>> {
605 let mut spans = Vec::new();
606 let mut first_section = true;
607
608 for highlight in &self.header_context.highlights {
609 let title = highlight.title.trim();
610 let summary = self.header_highlight_summary(highlight);
611
612 if title.is_empty() && summary.is_none() {
613 continue;
614 }
615
616 if !first_section {
617 spans.push(Span::styled(
618 ui::HEADER_META_SEPARATOR.to_owned(),
619 self.header_secondary_style(),
620 ));
621 }
622
623 if !title.is_empty() {
624 let mut title_style = self.header_secondary_style();
625 title_style = title_style.add_modifier(Modifier::BOLD);
626 let mut title_text = title.to_owned();
627 if summary.is_some() {
628 title_text.push(':');
629 }
630 spans.push(Span::styled(title_text, title_style));
631 if summary.is_some() {
632 spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
633 }
634 }
635
636 if let Some(body) = summary {
637 spans.push(Span::styled(body, self.header_primary_style()));
638 }
639
640 first_section = false;
641 }
642
643 if spans.is_empty() {
644 None
645 } else {
646 Some(Line::from(spans))
647 }
648 }
649
650 fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
651 let entries: Vec<String> = highlight
652 .lines
653 .iter()
654 .map(|line| line.trim())
655 .filter(|line| !line.is_empty())
656 .map(|line| {
657 let stripped = line
658 .strip_prefix("- ")
659 .or_else(|| line.strip_prefix("• "))
660 .unwrap_or(line);
661 stripped.trim().to_owned()
662 })
663 .collect();
664
665 if entries.is_empty() {
666 return None;
667 }
668
669 Some(self.compact_highlight_entries(&entries))
670 }
671
672 fn compact_highlight_entries(&self, entries: &[String]) -> String {
673 let mut summary =
674 self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
675 if entries.len() > 1 {
676 let remaining = entries.len() - 1;
677 if !summary.is_empty() {
678 let _ = write!(summary, " (+{} more)", remaining);
679 } else {
680 summary = format!("(+{} more)", remaining);
681 }
682 }
683 summary
684 }
685
686 fn truncate_highlight_preview(&self, text: &str) -> String {
687 let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
688 if max == 0 {
689 return String::new();
690 }
691
692 let grapheme_count = text.graphemes(true).count();
693 if grapheme_count <= max {
694 return text.to_owned();
695 }
696
697 let mut truncated = String::new();
701 truncated.reserve(text.len().min(max * 4));
703 for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
704 truncated.push_str(grapheme);
705 }
706 truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
707 truncated
708 }
709
710 fn should_show_suggestions(&self) -> bool {
712 self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
714 }
715
716 pub(crate) fn header_suggestions_line(&self) -> Option<Line<'static>> {
718 let dim = self.header_secondary_style().add_modifier(Modifier::DIM);
719 let key = self.header_primary_style().add_modifier(Modifier::BOLD);
720 let label = self.header_secondary_style();
721 let dot = Span::styled(" · ", dim);
722
723 let mut spans = vec![
724 Span::styled("/help", key),
725 dot.clone(),
726 Span::styled("/model", key),
727 dot.clone(),
728 Span::styled("/effort", key),
729 dot.clone(),
730 Span::styled("/config", key),
731 dot.clone(),
732 Span::styled("/clear", key),
733 Span::styled(" │ ", dim),
734 Span::styled("↑↓", key),
735 Span::styled(" nav", label),
736 dot.clone(),
737 Span::styled("Tab", key),
738 Span::styled(" complete", label),
739 ];
740
741 if self.has_delegated_local_agents() {
742 spans.push(Span::styled(" │ ", dim));
743 spans.push(Span::styled("Alt+S", key));
744 spans.push(Span::styled(" agents", label));
745 spans.push(dot.clone());
746 spans.push(Span::styled("Ctrl+B", key));
747 spans.push(Span::styled(" background", label));
748 }
749
750 Some(Line::from(spans))
751 }
752
753 pub(crate) fn section_title_style(&self) -> Style {
754 let mut style = self
755 .styles
756 .default_style()
757 .add_modifier(Modifier::BOLD | Modifier::DIM);
758 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
759 style = style.fg(ratatui_color_from_ansi(primary));
760 }
761 style
762 }
763
764 fn header_primary_style(&self) -> Style {
765 let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
766 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
767 style = style.fg(ratatui_color_from_ansi(primary));
768 }
769 style
770 }
771
772 pub(crate) fn header_secondary_style(&self) -> Style {
773 let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
774 if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
775 style = style.fg(ratatui_color_from_ansi(secondary));
776 }
777 style
778 }
779
780 fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
781 value.strip_prefix(prefix).unwrap_or(value)
782 }
783}