vtcode_tui/core_tui/session/
header.rs1use std::fmt::Write;
2
3use ratatui::{
4 Frame,
5 layout::Rect,
6 style::{Color, 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::{
15 InlineHeaderContext, InlineHeaderHighlight, InlineHeaderStatusBadge, InlineHeaderStatusTone,
16};
17use super::terminal_capabilities;
18use super::{Session, ratatui_color_from_ansi};
19
20fn clean_reasoning_text(text: &str) -> String {
21 text.lines()
22 .map(str::trim_end)
23 .filter(|line| !line.trim().is_empty())
24 .collect::<Vec<_>>()
25 .join("\n")
26}
27
28fn capitalize_first_letter(s: &str) -> String {
29 let mut chars = s.chars();
30 match chars.next() {
31 None => String::new(),
32 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
33 }
34}
35
36fn compact_tools_format(tools_str: &str) -> String {
37 let parts = tools_str.split(" · ");
41 let mut allow_count = 0;
42
43 for part in parts {
44 let trimmed = part.trim();
45 if let Some(num_str) = trimmed.strip_prefix("allow ")
46 && let Ok(num) = num_str.parse::<i32>()
47 {
48 allow_count = num;
49 break; }
51 }
52
53 allow_count.to_string()
54}
55
56fn compact_context_window_label(context_window_size: usize) -> String {
57 if context_window_size >= 1_000_000 {
58 format!("{}M", context_window_size / 1_000_000)
59 } else if context_window_size >= 1_000 {
60 format!("{}K", context_window_size / 1_000)
61 } else {
62 context_window_size.to_string()
63 }
64}
65
66fn line_is_empty(spans: &[Span<'static>]) -> bool {
67 spans.len() == 1 && spans.first().is_some_and(|span| span.content.is_empty())
68}
69
70fn header_status_badge_style(badge: &InlineHeaderStatusBadge, fallback: Style) -> Style {
71 let color = match badge.tone {
72 InlineHeaderStatusTone::Ready => Color::Green,
73 InlineHeaderStatusTone::Warning => Color::Yellow,
74 InlineHeaderStatusTone::Error => Color::Red,
75 };
76 fallback.fg(color).add_modifier(Modifier::BOLD)
77}
78
79impl Session {
80 pub(super) fn render_header(&self, frame: &mut Frame<'_>, area: Rect, lines: &[Line<'static>]) {
81 if area.height == 0 || area.width == 0 {
82 return;
83 }
84
85 let paragraph = self.build_header_paragraph(lines);
86
87 frame.render_widget(paragraph, area);
88 }
89
90 pub(crate) fn header_lines(&mut self) -> Vec<Line<'static>> {
91 if let Some(cached) = &self.header_lines_cache {
92 return cached.clone();
93 }
94
95 let lines = vec![self.header_compact_line()];
96 self.header_lines_cache = Some(lines.clone());
97 lines
98 }
99
100 pub(crate) fn header_height_from_lines(&mut self, width: u16, lines: &[Line<'static>]) -> u16 {
101 if width == 0 {
102 return self.header_rows.max(ui::INLINE_HEADER_HEIGHT);
103 }
104
105 if let Some(&height) = self.header_height_cache.get(&width) {
106 return height;
107 }
108
109 let paragraph = self.build_header_paragraph(lines);
110 let measured = paragraph.line_count(width);
111 let resolved = u16::try_from(measured).unwrap_or(u16::MAX);
112 let resolved = resolved.clamp(ui::INLINE_HEADER_HEIGHT, 3);
114 self.header_height_cache.insert(width, resolved);
115 resolved
116 }
117
118 pub(crate) fn build_header_paragraph(&self, lines: &[Line<'static>]) -> Paragraph<'static> {
119 let block = Block::bordered()
120 .title(self.header_block_title())
121 .border_type(terminal_capabilities::get_border_type())
122 .style(self.styles.default_style());
123
124 Paragraph::new(lines.to_vec())
125 .style(self.styles.default_style())
126 .wrap(Wrap { trim: true })
127 .block(block)
128 }
129
130 #[cfg(test)]
131 pub(super) fn header_height_for_width(&mut self, width: u16) -> u16 {
132 let lines = self.header_lines();
133 self.header_height_from_lines(width, &lines)
134 }
135
136 pub fn header_block_title(&self) -> 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 Line::from(vec![
161 Span::styled(prompt, prompt_style),
162 Span::styled(version_text, version_style),
163 ])
164 }
165
166 pub fn header_title_line(&self) -> Line<'static> {
167 let mut spans = Vec::new();
169
170 let provider = self.header_provider_short_value();
171 let model = self.header_model_short_value();
172 let reasoning = self.header_reasoning_short_value();
173
174 if !provider.is_empty() {
175 let capitalized_provider = capitalize_first_letter(&provider);
176 let mut style = self.header_primary_style();
177 style = style.add_modifier(Modifier::BOLD);
178 spans.push(Span::styled(capitalized_provider, style));
179 }
180
181 if !model.is_empty() {
182 if !spans.is_empty() {
183 spans.push(Span::raw(" "));
184 }
185 let mut style = self.header_primary_style();
186 style = style.add_modifier(Modifier::ITALIC);
187 spans.push(Span::styled(model, style));
188 }
189
190 if !reasoning.is_empty() {
191 if !spans.is_empty() {
192 spans.push(Span::raw(" "));
193 }
194 let mut style = self.header_secondary_style();
195 style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
196
197 if let Some(stage) = &self.header_context.reasoning_stage {
198 let mut stage_style = style;
199 stage_style = stage_style
200 .remove_modifier(Modifier::DIM)
201 .add_modifier(Modifier::BOLD);
202 spans.push(Span::styled(format!("[{}]", stage), stage_style));
203 spans.push(Span::raw(" "));
204 }
205
206 spans.push(Span::styled(reasoning.to_string(), style));
207 }
208
209 if spans.is_empty() {
210 spans.push(Span::raw(String::new()));
211 }
212
213 Line::from(spans)
214 }
215
216 fn header_compact_line(&self) -> Line<'static> {
217 let mut spans = self.header_title_line().spans;
218 if line_is_empty(&spans) {
219 spans.clear();
220 }
221
222 let mut meta_spans = self.header_meta_line().spans;
223 if line_is_empty(&meta_spans) {
224 meta_spans.clear();
225 }
226
227 let mut tail_spans = if self.should_show_suggestions() {
228 self.header_suggestions_line()
229 } else {
230 self.header_highlights_line()
231 }
232 .map(|line| line.spans)
233 .unwrap_or_default();
234
235 if line_is_empty(&tail_spans) {
236 tail_spans.clear();
237 }
238
239 let separator_style = self.header_secondary_style();
240 let separator = Span::styled(
241 ui::HEADER_MODE_PRIMARY_SEPARATOR.to_owned(),
242 separator_style,
243 );
244
245 let mut append_section = |section: &mut Vec<Span<'static>>| {
246 if section.is_empty() {
247 return;
248 }
249 if !spans.is_empty() {
250 spans.push(separator.clone());
251 }
252 spans.append(section);
253 };
254
255 append_section(&mut meta_spans);
256 append_section(&mut tail_spans);
257
258 if spans.is_empty() {
259 spans.push(Span::raw(String::new()));
260 }
261
262 Line::from(spans)
263 }
264
265 fn header_provider_value(&self) -> String {
266 let trimmed = self.header_context.provider.trim();
267 if trimmed.is_empty() {
268 InlineHeaderContext::default().provider
269 } else {
270 self.header_context.provider.clone()
271 }
272 }
273
274 fn header_model_value(&self) -> String {
275 let trimmed = self.header_context.model.trim();
276 if trimmed.is_empty() {
277 InlineHeaderContext::default().model
278 } else {
279 self.header_context.model.clone()
280 }
281 }
282
283 fn header_mode_label(&self) -> String {
284 let trimmed = self.header_context.mode.trim();
285 if trimmed.is_empty() {
286 InlineHeaderContext::default().mode
287 } else {
288 self.header_context.mode.clone()
289 }
290 }
291
292 pub fn header_mode_short_label(&self) -> String {
293 let full = self.header_mode_label();
294 let value = full.trim();
295 if value.eq_ignore_ascii_case(ui::HEADER_MODE_AUTO) {
296 return "Auto".to_owned();
297 }
298 if value.eq_ignore_ascii_case(ui::HEADER_MODE_INLINE) {
299 return "Inline".to_owned();
300 }
301 if value.eq_ignore_ascii_case(ui::HEADER_MODE_ALTERNATE) {
302 return "Alternate".to_owned();
303 }
304
305 if value.to_lowercase() == "std" {
307 return "Session: Standard".to_owned();
308 }
309
310 let compact = value
311 .strip_suffix(ui::HEADER_MODE_FULL_AUTO_SUFFIX)
312 .unwrap_or(value)
313 .trim();
314 compact.to_owned()
315 }
316
317 fn header_reasoning_value(&self) -> Option<String> {
318 let raw_reasoning = &self.header_context.reasoning;
319 let cleaned = clean_reasoning_text(raw_reasoning);
320 let trimmed = cleaned.trim();
321 let value = if trimmed.is_empty() {
322 InlineHeaderContext::default().reasoning
323 } else {
324 cleaned
325 };
326 if value.trim().is_empty() {
327 None
328 } else {
329 Some(value)
330 }
331 }
332
333 pub fn header_provider_short_value(&self) -> String {
334 let value = self.header_provider_value();
335 Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX)
336 .trim()
337 .to_owned()
338 }
339
340 pub fn header_model_short_value(&self) -> String {
341 let value = self.header_model_value();
342 let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX)
343 .trim()
344 .to_owned();
345
346 match self.header_context.context_window_size {
347 Some(context_window_size) if context_window_size > 0 => {
348 format!(
349 "{} ({})",
350 model,
351 compact_context_window_label(context_window_size)
352 )
353 }
354 _ => model,
355 }
356 }
357
358 pub fn header_reasoning_short_value(&self) -> String {
359 let value = self.header_reasoning_value().unwrap_or_default();
360 Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX)
361 .trim()
362 .to_owned()
363 }
364
365 pub fn header_chain_values(&self) -> Vec<String> {
366 let defaults = InlineHeaderContext::default();
367 let mut values = Vec::new();
368
369 for (value, fallback) in [
370 (&self.header_context.tools, defaults.tools),
371 (&self.header_context.git, defaults.git),
372 ] {
373 let selected = if value.trim().is_empty() {
374 fallback
375 } else {
376 value.clone()
377 };
378 let trimmed = selected.trim();
379 if trimmed.is_empty() {
380 continue;
381 }
382
383 if let Some(body) = trimmed.strip_prefix(ui::HEADER_TOOLS_PREFIX) {
384 let compact_tools = compact_tools_format(body.trim());
385 values.push(format!("Tools: {}", compact_tools));
386 continue;
387 }
388
389 if let Some(body) = trimmed.strip_prefix(ui::HEADER_GIT_PREFIX) {
390 let body = body.trim();
391 if !body.is_empty() {
392 values.push(body.to_owned());
393 }
394 continue;
395 }
396
397 values.push(selected);
398 }
399
400 values
401 }
402
403 pub fn header_meta_line(&self) -> Line<'static> {
404 use super::super::types::EditingMode;
405
406 let mut spans = Vec::new();
407
408 let mut first_section = true;
409 let separator_style = self.header_secondary_style();
410
411 let push_badge =
412 |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
413 if !*first {
414 spans.push(Span::styled(
415 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
416 separator_style,
417 ));
418 }
419 spans.push(Span::styled(text, style));
420 *first = false;
421 };
422
423 if self.header_context.editing_mode == EditingMode::Plan {
425 let badge_style = Style::default()
426 .fg(Color::Yellow)
427 .add_modifier(Modifier::BOLD);
428 push_badge(
429 &mut spans,
430 "Plan mode on".to_string(),
431 badge_style,
432 &mut first_section,
433 );
434 }
435
436 if self.header_context.autonomous_mode {
438 let badge_style = Style::default()
439 .fg(Color::Green)
440 .add_modifier(Modifier::BOLD);
441 push_badge(
442 &mut spans,
443 "[AUTO]".to_string(),
444 badge_style,
445 &mut first_section,
446 );
447 }
448
449 let trust_value = self.header_context.workspace_trust.to_lowercase();
451 if trust_value.contains("full auto") || trust_value.contains("full_auto") {
452 let badge_style = Style::default()
453 .fg(Color::Cyan)
454 .add_modifier(Modifier::BOLD);
455 push_badge(
456 &mut spans,
457 "Accept edits".to_string(),
458 badge_style,
459 &mut first_section,
460 );
461 } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
462 let badge_style = Style::default()
463 .fg(Color::Green)
464 .add_modifier(Modifier::BOLD);
465 push_badge(
466 &mut spans,
467 "[SAFE]".to_string(),
468 badge_style,
469 &mut first_section,
470 );
471 }
472
473 if let Some(badge) = self
474 .header_context
475 .search_tools
476 .as_ref()
477 .filter(|badge| !badge.text.trim().is_empty())
478 {
479 if !first_section {
480 spans.push(Span::styled(
481 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
482 self.header_secondary_style(),
483 ));
484 }
485 let style = header_status_badge_style(badge, self.header_primary_style());
486 spans.push(Span::styled(badge.text.clone(), style));
487 first_section = false;
488 }
489
490 for value in self.header_chain_values() {
491 if !first_section {
492 spans.push(Span::styled(
493 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
494 self.header_secondary_style(),
495 ));
496 }
497 spans.push(Span::styled(value, self.header_primary_style()));
498 first_section = false;
499 }
500
501 if spans.is_empty() {
502 spans.push(Span::raw(String::new()));
503 }
504
505 Line::from(spans)
506 }
507
508 fn header_highlights_line(&self) -> Option<Line<'static>> {
509 let mut spans = Vec::new();
510 let mut first_section = true;
511
512 for highlight in &self.header_context.highlights {
513 let title = highlight.title.trim();
514 let summary = self.header_highlight_summary(highlight);
515
516 if title.is_empty() && summary.is_none() {
517 continue;
518 }
519
520 if !first_section {
521 spans.push(Span::styled(
522 ui::HEADER_META_SEPARATOR.to_owned(),
523 self.header_secondary_style(),
524 ));
525 }
526
527 if !title.is_empty() {
528 let mut title_style = self.header_secondary_style();
529 title_style = title_style.add_modifier(Modifier::BOLD);
530 let mut title_text = title.to_owned();
531 if summary.is_some() {
532 title_text.push(':');
533 }
534 spans.push(Span::styled(title_text, title_style));
535 if summary.is_some() {
536 spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
537 }
538 }
539
540 if let Some(body) = summary {
541 spans.push(Span::styled(body, self.header_primary_style()));
542 }
543
544 first_section = false;
545 }
546
547 if spans.is_empty() {
548 None
549 } else {
550 Some(Line::from(spans))
551 }
552 }
553
554 fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
555 let entries: Vec<String> = highlight
556 .lines
557 .iter()
558 .map(|line| line.trim())
559 .filter(|line| !line.is_empty())
560 .map(|line| {
561 let stripped = line
562 .strip_prefix("- ")
563 .or_else(|| line.strip_prefix("• "))
564 .unwrap_or(line);
565 stripped.trim().to_owned()
566 })
567 .collect();
568
569 if entries.is_empty() {
570 return None;
571 }
572
573 Some(self.compact_highlight_entries(&entries))
574 }
575
576 fn compact_highlight_entries(&self, entries: &[String]) -> String {
577 let mut summary =
578 self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
579 if entries.len() > 1 {
580 let remaining = entries.len() - 1;
581 if !summary.is_empty() {
582 let _ = write!(summary, " (+{} more)", remaining);
583 } else {
584 summary = format!("(+{} more)", remaining);
585 }
586 }
587 summary
588 }
589
590 fn truncate_highlight_preview(&self, text: &str) -> String {
591 let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
592 if max == 0 {
593 return String::new();
594 }
595
596 let grapheme_count = text.graphemes(true).count();
597 if grapheme_count <= max {
598 return text.to_owned();
599 }
600
601 let mut truncated = String::new();
605 truncated.reserve(text.len().min(max * 4));
607 for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
608 truncated.push_str(grapheme);
609 }
610 truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
611 truncated
612 }
613
614 fn should_show_suggestions(&self) -> bool {
616 self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
618 }
619
620 fn header_suggestions_line(&self) -> Option<Line<'static>> {
622 let spans = vec![
623 Span::styled(
624 "/help",
625 self.header_primary_style().add_modifier(Modifier::BOLD),
626 ),
627 Span::styled(
628 " · ",
629 self.header_secondary_style().add_modifier(Modifier::DIM),
630 ),
631 Span::styled(
632 "/model",
633 self.header_primary_style().add_modifier(Modifier::BOLD),
634 ),
635 Span::styled(
636 " | ",
637 self.header_secondary_style().add_modifier(Modifier::DIM),
638 ),
639 Span::styled(
640 "↑↓",
641 self.header_primary_style().add_modifier(Modifier::BOLD),
642 ),
643 Span::styled(" Nav · ", self.header_secondary_style()),
644 Span::styled(
645 "Tab",
646 self.header_primary_style().add_modifier(Modifier::BOLD),
647 ),
648 Span::styled(" Complete", self.header_secondary_style()),
649 ];
650
651 Some(Line::from(spans))
652 }
653
654 pub(super) fn section_title_style(&self) -> Style {
655 let mut style = self.styles.default_style().add_modifier(Modifier::BOLD);
656 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
657 style = style.fg(ratatui_color_from_ansi(primary));
658 }
659 style
660 }
661
662 fn header_primary_style(&self) -> Style {
663 let mut style = self.styles.default_style();
664 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
665 style = style.fg(ratatui_color_from_ansi(primary));
666 }
667 style
668 }
669
670 pub(super) fn header_secondary_style(&self) -> Style {
671 let mut style = self.styles.default_style();
672 if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
673 style = style.fg(ratatui_color_from_ansi(secondary));
674 }
675 style
676 }
677
678 fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
679 value.strip_prefix(prefix).unwrap_or(value)
680 }
681}