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