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