vtcode_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::config::constants::ui;
11
12use super::super::types::{
13 InlineHeaderContext, InlineHeaderHighlight, InlineHeaderStatusBadge, InlineHeaderStatusTone,
14};
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 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; }
49 }
50
51 allow_count.to_string()
52}
53
54fn compact_context_window_label(context_window_size: usize) -> String {
55 if context_window_size >= 1_000_000 {
56 format!("{}M", context_window_size / 1_000_000)
57 } else if context_window_size >= 1_000 {
58 format!("{}K", context_window_size / 1_000)
59 } else {
60 context_window_size.to_string()
61 }
62}
63
64fn line_is_empty(spans: &[Span<'static>]) -> bool {
65 spans.len() == 1 && spans.first().is_some_and(|span| span.content.is_empty())
66}
67
68fn header_status_badge_style(badge: &InlineHeaderStatusBadge, fallback: Style) -> Style {
69 let color = match badge.tone {
70 InlineHeaderStatusTone::Ready => Color::Green,
71 InlineHeaderStatusTone::Warning => Color::Yellow,
72 InlineHeaderStatusTone::Error => Color::Red,
73 };
74 fallback.fg(color).add_modifier(Modifier::BOLD)
75}
76
77impl Session {
78 pub(crate) fn header_lines(&mut self) -> Vec<Line<'static>> {
79 if let Some(cached) = &self.header_lines_cache {
80 return cached.clone();
81 }
82
83 let lines = vec![self.header_compact_line()];
84 self.header_lines_cache = Some(lines.clone());
85 lines
86 }
87
88 pub(crate) fn header_height_from_lines(&mut self, width: u16, lines: &[Line<'static>]) -> u16 {
89 if width == 0 {
90 return self.header_rows.max(ui::INLINE_HEADER_HEIGHT);
91 }
92
93 if let Some(&height) = self.header_height_cache.get(&width) {
94 return height;
95 }
96
97 let paragraph = self.build_header_paragraph(lines);
98 let measured = paragraph.line_count(width);
99 let resolved = u16::try_from(measured).unwrap_or(u16::MAX);
100 let resolved = resolved.clamp(ui::INLINE_HEADER_HEIGHT, 3);
102 self.header_height_cache.insert(width, resolved);
103 resolved
104 }
105
106 pub(crate) fn build_header_paragraph(&self, lines: &[Line<'static>]) -> Paragraph<'static> {
107 let block = Block::bordered()
108 .title(self.header_block_title())
109 .border_type(terminal_capabilities::get_border_type())
110 .style(self.styles.default_style());
111
112 Paragraph::new(lines.to_vec())
113 .style(self.styles.default_style())
114 .wrap(Wrap { trim: true })
115 .block(block)
116 }
117
118 #[cfg(test)]
119 pub(crate) fn header_height_for_width(&mut self, width: u16) -> u16 {
120 let lines = self.header_lines();
121 self.header_height_from_lines(width, &lines)
122 }
123
124 pub fn header_block_title(&self) -> Line<'static> {
125 let fallback = InlineHeaderContext::default();
126 let version = if self.header_context.version.trim().is_empty() {
127 fallback.version
128 } else {
129 self.header_context.version.clone()
130 };
131
132 let app_name = if self.header_context.app_name.trim().is_empty() {
133 ui::HEADER_VERSION_PREFIX
134 } else {
135 self.header_context.app_name.trim()
136 };
137 let prompt = format!("{}{} ", ui::HEADER_VERSION_PROMPT, app_name);
138 let version_text = format!(
139 "{}{}{}",
140 ui::HEADER_VERSION_LEFT_DELIMITER,
141 version.trim(),
142 ui::HEADER_VERSION_RIGHT_DELIMITER
143 );
144
145 let prompt_style = self.section_title_style();
146 let version_style = self.header_secondary_style().add_modifier(Modifier::DIM);
147
148 Line::from(vec![
149 Span::styled(prompt, prompt_style),
150 Span::styled(version_text, version_style),
151 ])
152 }
153
154 pub fn header_title_line(&self) -> Line<'static> {
155 let mut spans = Vec::new();
157
158 let provider = self.header_provider_short_value();
159 let model = self.header_model_short_value();
160 let reasoning = self.header_reasoning_short_value();
161
162 if !provider.is_empty() {
163 let capitalized_provider = capitalize_first_letter(&provider);
164 let mut style = self.header_primary_style();
165 style = style.add_modifier(Modifier::BOLD);
166 spans.push(Span::styled(capitalized_provider, style));
167 }
168
169 if !model.is_empty() {
170 if !spans.is_empty() {
171 spans.push(Span::raw(" "));
172 }
173 let mut style = self.header_primary_style();
174 style = style.add_modifier(Modifier::ITALIC);
175 spans.push(Span::styled(model, style));
176 }
177
178 if !reasoning.is_empty() {
179 if !spans.is_empty() {
180 spans.push(Span::raw(" "));
181 }
182 let mut style = self.header_secondary_style();
183 style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
184
185 if let Some(stage) = &self.header_context.reasoning_stage {
186 let mut stage_style = style;
187 stage_style = stage_style
188 .remove_modifier(Modifier::DIM)
189 .add_modifier(Modifier::BOLD);
190 spans.push(Span::styled(format!("[{}]", stage), stage_style));
191 spans.push(Span::raw(" "));
192 }
193
194 spans.push(Span::styled(reasoning.to_string(), style));
195 }
196
197 if spans.is_empty() {
198 spans.push(Span::raw(String::new()));
199 }
200
201 Line::from(spans)
202 }
203
204 fn header_compact_line(&self) -> Line<'static> {
205 let mut spans = self.header_title_line().spans;
206 if line_is_empty(&spans) {
207 spans.clear();
208 }
209
210 let mut meta_spans = self.header_meta_line().spans;
211 if line_is_empty(&meta_spans) {
212 meta_spans.clear();
213 }
214
215 let mut tail_spans = if self.should_show_suggestions() {
216 self.header_suggestions_line()
217 } else {
218 self.header_highlights_line()
219 }
220 .map(|line| line.spans)
221 .unwrap_or_default();
222
223 if line_is_empty(&tail_spans) {
224 tail_spans.clear();
225 }
226
227 let separator_style = self.header_secondary_style();
228 let separator = Span::styled(
229 ui::HEADER_MODE_PRIMARY_SEPARATOR.to_owned(),
230 separator_style,
231 );
232
233 let mut append_section = |section: &mut Vec<Span<'static>>| {
234 if section.is_empty() {
235 return;
236 }
237 if !spans.is_empty() {
238 spans.push(separator.clone());
239 }
240 spans.append(section);
241 };
242
243 append_section(&mut meta_spans);
244 append_section(&mut tail_spans);
245
246 if spans.is_empty() {
247 spans.push(Span::raw(String::new()));
248 }
249
250 Line::from(spans)
251 }
252
253 fn header_provider_value(&self) -> String {
254 let trimmed = self.header_context.provider.trim();
255 if trimmed.is_empty() {
256 InlineHeaderContext::default().provider
257 } else {
258 self.header_context.provider.clone()
259 }
260 }
261
262 fn header_model_value(&self) -> String {
263 let trimmed = self.header_context.model.trim();
264 if trimmed.is_empty() {
265 InlineHeaderContext::default().model
266 } else {
267 self.header_context.model.clone()
268 }
269 }
270
271 fn header_mode_label(&self) -> String {
272 let trimmed = self.header_context.mode.trim();
273 if trimmed.is_empty() {
274 InlineHeaderContext::default().mode
275 } else {
276 self.header_context.mode.clone()
277 }
278 }
279
280 pub fn header_mode_short_label(&self) -> String {
281 let full = self.header_mode_label();
282 let value = full.trim();
283 if value.eq_ignore_ascii_case(ui::HEADER_MODE_AUTO) {
284 return "Auto".to_owned();
285 }
286 if value.eq_ignore_ascii_case(ui::HEADER_MODE_INLINE) {
287 return "Inline".to_owned();
288 }
289 if value.eq_ignore_ascii_case(ui::HEADER_MODE_ALTERNATE) {
290 return "Alternate".to_owned();
291 }
292
293 if value.to_lowercase() == "std" {
295 return "Session: Standard".to_owned();
296 }
297
298 let compact = value
299 .strip_suffix(ui::HEADER_MODE_FULL_AUTO_SUFFIX)
300 .unwrap_or(value)
301 .trim();
302 compact.to_owned()
303 }
304
305 fn header_reasoning_value(&self) -> Option<String> {
306 let raw_reasoning = &self.header_context.reasoning;
307 let cleaned = clean_reasoning_text(raw_reasoning);
308 let trimmed = cleaned.trim();
309 let value = if trimmed.is_empty() {
310 InlineHeaderContext::default().reasoning
311 } else {
312 cleaned
313 };
314 if value.trim().is_empty() {
315 None
316 } else {
317 Some(value)
318 }
319 }
320
321 pub fn header_provider_short_value(&self) -> String {
322 let value = self.header_provider_value();
323 Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX)
324 .trim()
325 .to_owned()
326 }
327
328 pub fn header_model_short_value(&self) -> String {
329 let value = self.header_model_value();
330 let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX)
331 .trim()
332 .to_owned();
333
334 match self.header_context.context_window_size {
335 Some(context_window_size) if context_window_size > 0 => {
336 format!(
337 "{} ({})",
338 model,
339 compact_context_window_label(context_window_size)
340 )
341 }
342 _ => model,
343 }
344 }
345
346 pub fn header_reasoning_short_value(&self) -> String {
347 let value = self.header_reasoning_value().unwrap_or_default();
348 Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX)
349 .trim()
350 .to_owned()
351 }
352
353 pub fn header_chain_values(&self) -> Vec<String> {
354 let defaults = InlineHeaderContext::default();
355 let mut values = Vec::new();
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 let mut first_section = true;
397 let separator_style = self.header_secondary_style();
398
399 let push_badge =
400 |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
401 if !*first {
402 spans.push(Span::styled(
403 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
404 separator_style,
405 ));
406 }
407 spans.push(Span::styled(text, style));
408 *first = false;
409 };
410
411 if self.header_context.editing_mode == EditingMode::Plan {
413 let badge_style = Style::default()
414 .fg(Color::Yellow)
415 .add_modifier(Modifier::BOLD);
416 push_badge(
417 &mut spans,
418 "Plan mode on".to_string(),
419 badge_style,
420 &mut first_section,
421 );
422 }
423
424 if self.header_context.autonomous_mode {
426 let badge_style = Style::default()
427 .fg(Color::Green)
428 .add_modifier(Modifier::BOLD);
429 push_badge(
430 &mut spans,
431 "[AUTO]".to_string(),
432 badge_style,
433 &mut first_section,
434 );
435 }
436
437 let trust_value = self.header_context.workspace_trust.to_lowercase();
439 if trust_value.contains("full auto") || trust_value.contains("full_auto") {
440 let badge_style = Style::default()
441 .fg(Color::Cyan)
442 .add_modifier(Modifier::BOLD);
443 push_badge(
444 &mut spans,
445 "Accept edits".to_string(),
446 badge_style,
447 &mut first_section,
448 );
449 } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
450 let badge_style = Style::default()
451 .fg(Color::Green)
452 .add_modifier(Modifier::BOLD);
453 push_badge(
454 &mut spans,
455 "[SAFE]".to_string(),
456 badge_style,
457 &mut first_section,
458 );
459 }
460
461 if let Some(badge) = self
462 .header_context
463 .search_tools
464 .as_ref()
465 .filter(|badge| !badge.text.trim().is_empty())
466 {
467 if !first_section {
468 spans.push(Span::styled(
469 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
470 self.header_secondary_style(),
471 ));
472 }
473 let style = header_status_badge_style(badge, self.header_primary_style());
474 spans.push(Span::styled(badge.text.clone(), style));
475 first_section = false;
476 }
477
478 if let Some(badge) = self
479 .header_context
480 .pr_review
481 .as_ref()
482 .filter(|badge| !badge.text.trim().is_empty())
483 {
484 if !first_section {
485 spans.push(Span::styled(
486 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
487 self.header_secondary_style(),
488 ));
489 }
490 let style = header_status_badge_style(badge, self.header_primary_style());
491 spans.push(Span::styled(badge.text.clone(), style));
492 first_section = false;
493 }
494
495 for value in self.header_chain_values() {
496 if !first_section {
497 spans.push(Span::styled(
498 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
499 self.header_secondary_style(),
500 ));
501 }
502 spans.push(Span::styled(value, self.header_primary_style()));
503 first_section = false;
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 fn header_suggestions_line(&self) -> Option<Line<'static>> {
627 let spans = vec![
628 Span::styled(
629 "/help",
630 self.header_primary_style().add_modifier(Modifier::BOLD),
631 ),
632 Span::styled(
633 " · ",
634 self.header_secondary_style().add_modifier(Modifier::DIM),
635 ),
636 Span::styled(
637 "/model",
638 self.header_primary_style().add_modifier(Modifier::BOLD),
639 ),
640 Span::styled(
641 " | ",
642 self.header_secondary_style().add_modifier(Modifier::DIM),
643 ),
644 Span::styled(
645 "↑↓",
646 self.header_primary_style().add_modifier(Modifier::BOLD),
647 ),
648 Span::styled(" Nav · ", self.header_secondary_style()),
649 Span::styled(
650 "Tab",
651 self.header_primary_style().add_modifier(Modifier::BOLD),
652 ),
653 Span::styled(" Complete", self.header_secondary_style()),
654 ];
655
656 Some(Line::from(spans))
657 }
658
659 pub(crate) fn section_title_style(&self) -> Style {
660 let mut style = self.styles.default_style().add_modifier(Modifier::BOLD);
661 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
662 style = style.fg(ratatui_color_from_ansi(primary));
663 }
664 style
665 }
666
667 fn header_primary_style(&self) -> Style {
668 let mut style = self.styles.default_style();
669 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
670 style = style.fg(ratatui_color_from_ansi(primary));
671 }
672 style
673 }
674
675 pub(crate) fn header_secondary_style(&self) -> Style {
676 let mut style = self.styles.default_style();
677 if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
678 style = style.fg(ratatui_color_from_ansi(secondary));
679 }
680 style
681 }
682
683 fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
684 value.strip_prefix(prefix).unwrap_or(value)
685 }
686}