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