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 if let Some(editor_context) = self
349 .header_context
350 .editor_context
351 .as_ref()
352 .filter(|value| !value.trim().is_empty())
353 {
354 values.push(editor_context.clone());
355 }
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 match self.header_context.editing_mode {
398 EditingMode::Plan => {
399 let badge_style = Style::default()
401 .fg(Color::Yellow)
402 .add_modifier(Modifier::BOLD);
403 spans.push(Span::styled("Plan mode on".to_string(), badge_style));
404 spans.push(Span::styled(
405 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
406 self.header_secondary_style(),
407 ));
408 }
409 EditingMode::Edit => {
410 }
412 }
413
414 if self.header_context.autonomous_mode {
416 let badge_style = Style::default()
417 .fg(Color::Green)
418 .add_modifier(Modifier::BOLD);
419 spans.push(Span::styled("[AUTO]".to_string(), badge_style));
420 spans.push(Span::styled(
421 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
422 self.header_secondary_style(),
423 ));
424 }
425
426 let trust_value = self.header_context.workspace_trust.to_lowercase();
428 if trust_value.contains("full auto") || trust_value.contains("full_auto") {
429 let badge_style = Style::default()
431 .fg(Color::Cyan)
432 .add_modifier(Modifier::BOLD);
433 spans.push(Span::styled("Accept edits".to_string(), badge_style));
434 spans.push(Span::styled(
435 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
436 self.header_secondary_style(),
437 ));
438 } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
439 let badge_style = Style::default()
441 .fg(Color::Green)
442 .add_modifier(Modifier::BOLD);
443 spans.push(Span::styled("[SAFE]".to_string(), badge_style));
444 spans.push(Span::styled(
445 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
446 self.header_secondary_style(),
447 ));
448 }
449
450 let mut first_section = spans.is_empty();
451 let mode_label = self.header_mode_short_label();
452 if !mode_label.trim().is_empty() {
453 let mut mode_style = self.header_primary_style().add_modifier(Modifier::BOLD);
454 if self.header_context.editing_mode == EditingMode::Plan {
455 mode_style = mode_style.fg(Color::Yellow);
456 } else if self.header_context.autonomous_mode {
457 mode_style = mode_style.fg(Color::Green);
458 }
459 spans.push(Span::styled(mode_label, mode_style));
460 first_section = false;
461 }
462
463 if let Some(badge) = self
464 .header_context
465 .search_tools
466 .as_ref()
467 .filter(|badge| !badge.text.trim().is_empty())
468 {
469 if !first_section {
470 spans.push(Span::styled(
471 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
472 self.header_secondary_style(),
473 ));
474 }
475 let style = header_status_badge_style(badge, self.header_primary_style());
476 spans.push(Span::styled(badge.text.clone(), style));
477 first_section = false;
478 }
479
480 for value in self.header_chain_values() {
481 if !first_section {
482 spans.push(Span::styled(
483 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
484 self.header_secondary_style(),
485 ));
486 }
487 spans.push(Span::styled(value, self.header_primary_style()));
488 first_section = false;
489 }
490
491 if spans.is_empty() {
492 spans.push(Span::raw(String::new()));
493 }
494
495 Line::from(spans)
496 }
497
498 fn header_highlights_line(&self) -> Option<Line<'static>> {
499 let mut spans = Vec::new();
500 let mut first_section = true;
501
502 for highlight in &self.header_context.highlights {
503 let title = highlight.title.trim();
504 let summary = self.header_highlight_summary(highlight);
505
506 if title.is_empty() && summary.is_none() {
507 continue;
508 }
509
510 if !first_section {
511 spans.push(Span::styled(
512 ui::HEADER_META_SEPARATOR.to_owned(),
513 self.header_secondary_style(),
514 ));
515 }
516
517 if !title.is_empty() {
518 let mut title_style = self.header_secondary_style();
519 title_style = title_style.add_modifier(Modifier::BOLD);
520 let mut title_text = title.to_owned();
521 if summary.is_some() {
522 title_text.push(':');
523 }
524 spans.push(Span::styled(title_text, title_style));
525 if summary.is_some() {
526 spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
527 }
528 }
529
530 if let Some(body) = summary {
531 spans.push(Span::styled(body, self.header_primary_style()));
532 }
533
534 first_section = false;
535 }
536
537 if spans.is_empty() {
538 None
539 } else {
540 Some(Line::from(spans))
541 }
542 }
543
544 fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
545 let entries: Vec<String> = highlight
546 .lines
547 .iter()
548 .map(|line| line.trim())
549 .filter(|line| !line.is_empty())
550 .map(|line| {
551 let stripped = line
552 .strip_prefix("- ")
553 .or_else(|| line.strip_prefix("• "))
554 .unwrap_or(line);
555 stripped.trim().to_owned()
556 })
557 .collect();
558
559 if entries.is_empty() {
560 return None;
561 }
562
563 Some(self.compact_highlight_entries(&entries))
564 }
565
566 fn compact_highlight_entries(&self, entries: &[String]) -> String {
567 let mut summary =
568 self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
569 if entries.len() > 1 {
570 let remaining = entries.len() - 1;
571 if !summary.is_empty() {
572 let _ = write!(summary, " (+{} more)", remaining);
573 } else {
574 summary = format!("(+{} more)", remaining);
575 }
576 }
577 summary
578 }
579
580 fn truncate_highlight_preview(&self, text: &str) -> String {
581 let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
582 if max == 0 {
583 return String::new();
584 }
585
586 let grapheme_count = text.graphemes(true).count();
587 if grapheme_count <= max {
588 return text.to_owned();
589 }
590
591 let mut truncated = String::new();
595 truncated.reserve(text.len().min(max * 4));
597 for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
598 truncated.push_str(grapheme);
599 }
600 truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
601 truncated
602 }
603
604 fn should_show_suggestions(&self) -> bool {
606 self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
608 }
609
610 fn header_suggestions_line(&self) -> Option<Line<'static>> {
612 let spans = vec![
613 Span::styled(
614 "/help",
615 self.header_primary_style().add_modifier(Modifier::BOLD),
616 ),
617 Span::styled(
618 " · ",
619 self.header_secondary_style().add_modifier(Modifier::DIM),
620 ),
621 Span::styled(
622 "/model",
623 self.header_primary_style().add_modifier(Modifier::BOLD),
624 ),
625 Span::styled(
626 " | ",
627 self.header_secondary_style().add_modifier(Modifier::DIM),
628 ),
629 Span::styled(
630 "↑↓",
631 self.header_primary_style().add_modifier(Modifier::BOLD),
632 ),
633 Span::styled(" Nav · ", self.header_secondary_style()),
634 Span::styled(
635 "Tab",
636 self.header_primary_style().add_modifier(Modifier::BOLD),
637 ),
638 Span::styled(" Complete", self.header_secondary_style()),
639 ];
640
641 Some(Line::from(spans))
642 }
643
644 pub(super) fn section_title_style(&self) -> Style {
645 let mut style = self.styles.default_style().add_modifier(Modifier::BOLD);
646 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
647 style = style.fg(ratatui_color_from_ansi(primary));
648 }
649 style
650 }
651
652 fn header_primary_style(&self) -> Style {
653 let mut style = self.styles.default_style();
654 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
655 style = style.fg(ratatui_color_from_ansi(primary));
656 }
657 style
658 }
659
660 pub(super) fn header_secondary_style(&self) -> Style {
661 let mut style = self.styles.default_style();
662 if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
663 style = style.fg(ratatui_color_from_ansi(secondary));
664 }
665 style
666 }
667
668 fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
669 value.strip_prefix(prefix).unwrap_or(value)
670 }
671}