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