1use std::fmt::Write;
2
3use ratatui::{
4 style::{Color, Modifier, Style},
5 text::{Line, Span},
6 widgets::{Block, Paragraph, Wrap},
7};
8use unicode_segmentation::UnicodeSegmentation;
9
10use crate::config::constants::ui;
11
12use super::super::types::{
13 InlineHeaderBadge, InlineHeaderContext, InlineHeaderHighlight, InlineHeaderStatusBadge,
14 InlineHeaderStatusTone,
15};
16use super::terminal_capabilities;
17use super::utils::line_truncation::truncate_line_with_ellipsis_if_overflow;
18use super::{Session, ratatui_color_from_ansi, ratatui_style_from_inline};
19
20fn clean_reasoning_text(text: &str) -> String {
21 vtcode_commons::formatting::clean_reasoning_text(text)
22}
23
24fn capitalize_first_letter(s: &str) -> String {
25 let mut chars = s.chars();
26 match chars.next() {
27 None => String::new(),
28 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
29 }
30}
31
32fn format_model_summary_label(model: &str) -> String {
33 model
34 .split('-')
35 .map(capitalize_first_letter)
36 .collect::<Vec<_>>()
37 .join("-")
38}
39
40fn primary_agent_header_label(name: Option<&str>) -> String {
41 let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
42 return "Build".to_string();
43 };
44 format_model_summary_label(name)
45}
46
47fn compact_context_window_label(context_window_size: usize) -> String {
48 if context_window_size >= 1_000_000 {
49 format!("{}M", context_window_size / 1_000_000)
50 } else if context_window_size >= 1_000 {
51 format!("{}K", context_window_size / 1_000)
52 } else {
53 context_window_size.to_string()
54 }
55}
56
57fn line_is_empty(spans: &[Span<'static>]) -> bool {
58 spans.len() == 1 && spans.first().is_some_and(|span| span.content.is_empty())
59}
60
61fn header_status_badge_style(badge: &InlineHeaderStatusBadge, fallback: Style) -> Style {
62 let color = match badge.tone {
63 InlineHeaderStatusTone::Ready => Color::Green,
64 InlineHeaderStatusTone::Warning => Color::Yellow,
65 InlineHeaderStatusTone::Error => Color::Red,
66 };
67 fallback.fg(color).add_modifier(Modifier::BOLD)
68}
69
70fn header_context_badge_style(badge: &InlineHeaderBadge) -> Style {
71 let mut style = ratatui_style_from_inline(&badge.style, None);
72 if badge.full_background {
73 style = style.add_modifier(Modifier::BOLD);
74 }
75 style
76}
77
78impl Session {
79 pub(crate) fn header_lines(&mut self) -> Vec<Line<'static>> {
80 if let Some(cached) = &self.header_lines_cache {
81 return cached.clone();
82 }
83
84 let lines = if self.appearance.hide_header {
85 vec![self.header_block_title_for_width(self.transcript_width)]
86 } else {
87 vec![self.header_compact_line()]
88 };
89 self.header_lines_cache = Some(lines.clone());
90 lines
91 }
92
93 pub(crate) fn header_height_from_lines(&mut self, width: u16, lines: &[Line<'static>]) -> u16 {
94 if self.appearance.hide_header {
95 return 1;
96 }
97
98 if width == 0 {
99 return self.header_rows.max(ui::INLINE_HEADER_HEIGHT);
100 }
101
102 if let Some(&height) = self.header_height_cache.get(&width) {
103 return height;
104 }
105
106 let paragraph = self.build_header_paragraph(lines);
107 let measured = paragraph.line_count(width);
108 let resolved = u16::try_from(measured).unwrap_or(u16::MAX);
109 let resolved = resolved.clamp(ui::INLINE_HEADER_HEIGHT, 3);
111 self.header_height_cache.insert(width, resolved);
112 resolved
113 }
114
115 pub(crate) fn build_header_paragraph(&self, lines: &[Line<'static>]) -> Paragraph<'static> {
116 let text_style = self.header_primary_style().add_modifier(Modifier::DIM);
117
118 if self.appearance.hide_header {
119 return Paragraph::new(lines.to_vec())
120 .style(text_style)
121 .wrap(Wrap { trim: true });
122 }
123
124 let mut border_style = Style::default();
125 if let Some(accent) = self
126 .theme
127 .tool_accent
128 .or(self.theme.primary)
129 .or(self.theme.foreground)
130 {
131 border_style = border_style.fg(ratatui_color_from_ansi(accent));
132 }
133 let text_style = self.header_primary_style().add_modifier(Modifier::DIM);
134 let block = Block::bordered()
135 .title(self.header_block_title())
136 .border_type(terminal_capabilities::get_border_type())
137 .border_style(border_style)
138 .style(self.styles.default_style());
139
140 Paragraph::new(lines.to_vec())
141 .style(text_style)
142 .wrap(Wrap { trim: true })
143 .block(block)
144 }
145
146 #[cfg(test)]
147 pub(crate) fn header_height_for_width(&mut self, width: u16) -> u16 {
148 let lines = self.header_lines();
149 self.header_height_from_lines(width, &lines)
150 }
151
152 pub fn header_block_title(&self) -> Line<'static> {
153 self.header_block_title_for_width(0)
154 }
155
156 fn header_block_title_for_width(&self, width: u16) -> Line<'static> {
157 let fallback = InlineHeaderContext::default();
158 let version = if self.header_context.version.trim().is_empty() {
159 fallback.version
160 } else {
161 self.header_context.version.clone()
162 };
163
164 let app_name = if self.header_context.app_name.trim().is_empty() {
165 ui::HEADER_VERSION_PREFIX
166 } else {
167 self.header_context.app_name.trim()
168 };
169 let prompt = format!("{}{}", ui::HEADER_VERSION_PROMPT, app_name);
170 let version_text = format!(
171 " {}{}{}",
172 ui::HEADER_VERSION_LEFT_DELIMITER,
173 version.trim(),
174 ui::HEADER_VERSION_RIGHT_DELIMITER
175 );
176
177 let prompt_style = self.section_title_style();
178 let version_style = self.header_secondary_style().add_modifier(Modifier::DIM);
179
180 let mut spans = vec![
181 Span::styled(prompt, prompt_style),
182 Span::styled(version_text, version_style),
183 ];
184
185 if self.appearance.hide_header && width > 0 {
186 let right_spans = self.header_compact_right_spans();
187 if right_spans.is_empty() {
188 return Line::from(spans);
189 }
190
191 let left_width = spans.iter().map(Span::width).sum::<usize>();
192 let summary_width = right_spans.iter().map(Span::width).sum::<usize>();
193 let available_width = usize::from(width);
194 let spacer_width = available_width
195 .saturating_sub(left_width.saturating_add(summary_width))
196 .max(1);
197 spans.push(Span::raw(" ".repeat(spacer_width)));
198 spans.extend(right_spans);
199 }
200
201 let line = Line::from(spans);
202 if width > 0 {
203 truncate_line_with_ellipsis_if_overflow(line, usize::from(width))
204 } else {
205 line
206 }
207 }
208
209 fn header_compact_right_spans(&self) -> Vec<Span<'static>> {
210 let mut spans = Vec::new();
211 let agent_label = primary_agent_header_label(self.header_context.primary_agent.as_deref());
212 let model_summary_spans = self.header_compact_model_summary_spans();
213 if !agent_label.trim().is_empty() {
214 spans.push(Span::styled(
215 agent_label,
216 Style::default()
217 .fg(Color::Magenta)
218 .add_modifier(Modifier::BOLD),
219 ));
220 }
221
222 if let Some((mode_text, mode_style)) = self.header_active_mode_summary() {
223 if !spans.is_empty() {
224 spans.push(Span::styled(
225 " · ".to_owned(),
226 self.header_secondary_style(),
227 ));
228 }
229 spans.push(Span::styled(mode_text, mode_style));
230 }
231
232 if !spans.is_empty() && !model_summary_spans.is_empty() {
233 spans.push(Span::styled(
234 " · ".to_owned(),
235 self.header_secondary_style(),
236 ));
237 }
238 spans.extend(model_summary_spans);
239
240 spans
241 }
242
243 fn header_compact_model_summary_spans(&self) -> Vec<Span<'static>> {
244 let provider = self.header_provider_short_value();
245 let model = self.header_model_short_value();
246 let reasoning = self.header_reasoning_short_value();
247 let mut spans = Vec::new();
248 let mut provider_model_parts = Vec::new();
249
250 match (
251 !provider.is_empty() && !provider.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
252 !model.is_empty() && !model.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
253 ) {
254 (true, true) => provider_model_parts.push(format!(
255 "{} {}",
256 capitalize_first_letter(&provider),
257 format_model_summary_label(&model)
258 )),
259 (true, false) => provider_model_parts.push(capitalize_first_letter(&provider)),
260 (false, true) => provider_model_parts.push(format_model_summary_label(&model)),
261 (false, false) => {}
262 }
263
264 if let Some(summary) =
265 (!provider_model_parts.is_empty()).then(|| provider_model_parts.join(" "))
266 {
267 spans.push(Span::styled(
268 summary,
269 self.header_secondary_style().add_modifier(Modifier::DIM),
270 ));
271 }
272
273 if !reasoning.is_empty() && !reasoning.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER)
274 {
275 if !spans.is_empty() {
276 spans.push(Span::styled(
277 " · ".to_owned(),
278 self.header_secondary_style(),
279 ));
280 }
281 let label_style = self
282 .header_secondary_style()
283 .add_modifier(Modifier::ITALIC | Modifier::DIM);
284 let value_style = self.header_secondary_style().add_modifier(Modifier::ITALIC);
285 spans.push(Span::styled("effort:".to_owned(), label_style));
286 spans.push(Span::styled(" ".to_owned(), label_style));
287 spans.push(Span::styled(reasoning, value_style));
288 }
289
290 spans
291 }
292
293 fn header_active_mode_summary(&self) -> Option<(String, Style)> {
294 use super::super::types::EditingMode;
295
296 if self.header_context.autonomous_mode {
297 return Some((
298 "Auto".to_string(),
299 Style::default()
300 .fg(Color::Green)
301 .add_modifier(Modifier::BOLD),
302 ));
303 }
304
305 match self.header_context.editing_mode {
306 EditingMode::Plan => Some((
307 "Plan".to_string(),
308 Style::default()
309 .fg(Color::Yellow)
310 .add_modifier(Modifier::BOLD),
311 )),
312 EditingMode::Edit => Some((
313 "Edit".to_string(),
314 Style::default()
315 .fg(Color::Cyan)
316 .add_modifier(Modifier::BOLD),
317 )),
318 }
319 }
320
321 pub fn header_title_line(&self) -> Line<'static> {
322 let mut spans = Vec::new();
324
325 let provider = self.header_provider_short_value();
326 let model = self.header_model_short_value();
327 let reasoning = self.header_reasoning_short_value();
328
329 if !provider.is_empty() {
330 let capitalized_provider = capitalize_first_letter(&provider);
331 let mut style = self.header_primary_style();
332 style = style.add_modifier(Modifier::BOLD);
333 spans.push(Span::styled(capitalized_provider, style));
334 }
335
336 if !model.is_empty() {
337 if !spans.is_empty() {
338 spans.push(Span::raw(" "));
339 }
340 let mut style = self.header_primary_style();
341 style = style.add_modifier(Modifier::ITALIC);
342 spans.push(Span::styled(model, style));
343 }
344
345 if !reasoning.is_empty() {
346 if !spans.is_empty() {
347 spans.push(Span::raw(" "));
348 }
349 let mut style = self.header_secondary_style();
350 style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
351
352 if let Some(stage) = &self.header_context.reasoning_stage {
353 let mut stage_style = style;
354 stage_style = stage_style
355 .remove_modifier(Modifier::DIM)
356 .add_modifier(Modifier::BOLD);
357 spans.push(Span::styled(format!("[{}]", stage), stage_style));
358 spans.push(Span::raw(" "));
359 }
360
361 spans.push(Span::styled(reasoning.to_string(), style));
362 }
363
364 if spans.is_empty() {
365 spans.push(Span::raw(String::new()));
366 }
367
368 Line::from(spans)
369 }
370
371 fn header_compact_line(&self) -> Line<'static> {
372 let mut spans = self.header_title_line().spans;
373 if line_is_empty(&spans) {
374 spans.clear();
375 }
376
377 let mut meta_spans = self.header_meta_line().spans;
378 if line_is_empty(&meta_spans) {
379 meta_spans.clear();
380 }
381
382 let mut tail_spans = if self.should_show_suggestions() {
383 self.header_suggestions_line()
384 } else {
385 self.header_highlights_line()
386 }
387 .map(|line| line.spans)
388 .unwrap_or_default();
389
390 if line_is_empty(&tail_spans) {
391 tail_spans.clear();
392 }
393
394 let separator_style = self.header_secondary_style();
395 let separator = Span::styled(
396 ui::HEADER_MODE_PRIMARY_SEPARATOR.to_owned(),
397 separator_style,
398 );
399
400 let mut append_section = |section: &mut Vec<Span<'static>>| {
401 if section.is_empty() {
402 return;
403 }
404 if !spans.is_empty() {
405 spans.push(separator.clone());
406 }
407 spans.append(section);
408 };
409
410 append_section(&mut meta_spans);
411 append_section(&mut tail_spans);
412
413 if spans.is_empty() {
414 spans.push(Span::raw(String::new()));
415 }
416
417 Line::from(spans)
418 }
419
420 fn header_provider_value(&self) -> String {
421 let trimmed = self.header_context.provider.trim();
422 if trimmed.is_empty() {
423 InlineHeaderContext::default().provider
424 } else {
425 self.header_context.provider.clone()
426 }
427 }
428
429 fn header_model_value(&self) -> String {
430 let trimmed = self.header_context.model.trim();
431 if trimmed.is_empty() {
432 InlineHeaderContext::default().model
433 } else {
434 self.header_context.model.clone()
435 }
436 }
437
438 fn header_mode_label(&self) -> String {
439 let trimmed = self.header_context.mode.trim();
440 if trimmed.is_empty() {
441 InlineHeaderContext::default().mode
442 } else {
443 self.header_context.mode.clone()
444 }
445 }
446
447 pub fn header_mode_short_label(&self) -> String {
448 let full = self.header_mode_label();
449 let value = full.trim();
450 if value.eq_ignore_ascii_case(ui::HEADER_MODE_AUTO) {
451 return "Auto".to_owned();
452 }
453 if value.eq_ignore_ascii_case(ui::HEADER_MODE_INLINE) {
454 return "Inline".to_owned();
455 }
456 if value.eq_ignore_ascii_case(ui::HEADER_MODE_ALTERNATE) {
457 return "Alternate".to_owned();
458 }
459
460 if value.to_lowercase() == "std" {
462 return "Session: Standard".to_owned();
463 }
464
465 let compact = value
466 .strip_suffix(ui::HEADER_MODE_FULL_AUTO_SUFFIX)
467 .unwrap_or(value)
468 .trim();
469 compact.to_owned()
470 }
471
472 fn header_reasoning_value(&self) -> Option<String> {
473 let raw_reasoning = &self.header_context.reasoning;
474 let cleaned = clean_reasoning_text(raw_reasoning);
475 let trimmed = cleaned.trim();
476 let value = if trimmed.is_empty() {
477 InlineHeaderContext::default().reasoning
478 } else {
479 cleaned
480 };
481 if value.trim().is_empty() {
482 None
483 } else {
484 Some(value)
485 }
486 }
487
488 pub fn header_provider_short_value(&self) -> String {
489 let value = self.header_provider_value();
490 Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX)
491 .trim()
492 .to_owned()
493 }
494
495 pub fn header_model_short_value(&self) -> String {
496 let value = self.header_model_value();
497 let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX)
498 .trim()
499 .to_owned();
500
501 match self.header_context.context_window_size {
502 Some(context_window_size) if context_window_size > 0 => {
503 format!(
504 "{} ({})",
505 model,
506 compact_context_window_label(context_window_size)
507 )
508 }
509 _ => model,
510 }
511 }
512
513 pub fn header_reasoning_short_value(&self) -> String {
514 let value = self.header_reasoning_value().unwrap_or_default();
515 Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX)
516 .trim()
517 .to_owned()
518 }
519
520 pub fn header_chain_values(&self) -> Vec<String> {
521 let mut values = Vec::new();
522
523 for value in [
524 &self.header_context.tools,
525 &self.header_context.git,
526 &self.header_context.mcp,
527 ] {
528 let trimmed = value.trim();
529 if trimmed.is_empty() {
530 continue;
531 }
532
533 if trimmed.starts_with(ui::HEADER_TOOLS_PREFIX)
534 || trimmed.starts_with(ui::HEADER_GIT_PREFIX)
535 {
536 continue;
537 }
538
539 if let Some(body) = trimmed.strip_prefix(ui::HEADER_MCP_PREFIX) {
540 let body = body.trim();
541 if body.is_empty() || body.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER) {
542 continue;
543 }
544 values.push(format!("MCP: {}", body));
545 continue;
546 }
547
548 values.push(trimmed.to_owned());
549 }
550
551 values
552 }
553
554 pub fn header_meta_line(&self) -> Line<'static> {
555 use super::super::types::EditingMode;
556
557 let mut spans = Vec::new();
558
559 let mut first_section = true;
560 let separator_style = self.header_secondary_style();
561
562 let push_badge =
563 |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
564 if !*first {
565 spans.push(Span::styled(
566 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
567 separator_style,
568 ));
569 }
570 spans.push(Span::styled(text, style));
571 *first = false;
572 };
573
574 let agent_style = Style::default()
575 .fg(Color::Magenta)
576 .add_modifier(Modifier::BOLD);
577 push_badge(
578 &mut spans,
579 primary_agent_header_label(self.header_context.primary_agent.as_deref()),
580 agent_style,
581 &mut first_section,
582 );
583
584 if self.header_context.editing_mode == EditingMode::Plan {
586 let badge_style = Style::default()
587 .fg(Color::Yellow)
588 .add_modifier(Modifier::BOLD);
589 push_badge(
590 &mut spans,
591 "Plan".to_string(),
592 badge_style,
593 &mut first_section,
594 );
595 }
596
597 if self.header_context.autonomous_mode {
599 let badge_style = Style::default()
600 .fg(Color::Green)
601 .add_modifier(Modifier::BOLD);
602 push_badge(
603 &mut spans,
604 "Auto".to_string(),
605 badge_style,
606 &mut first_section,
607 );
608 }
609
610 let trust_value = self.header_context.workspace_trust.to_lowercase();
612 if trust_value.contains("full auto") || trust_value.contains("full_auto") {
613 let badge_style = Style::default()
614 .fg(Color::Cyan)
615 .add_modifier(Modifier::BOLD);
616 push_badge(
617 &mut spans,
618 "Full-auto".to_string(),
619 badge_style,
620 &mut first_section,
621 );
622 } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
623 let badge_style = Style::default()
624 .fg(Color::Green)
625 .add_modifier(Modifier::BOLD);
626 push_badge(
627 &mut spans,
628 "Safe".to_string(),
629 badge_style,
630 &mut first_section,
631 );
632 }
633
634 if let Some(badge) = self
635 .header_context
636 .persistent_memory
637 .as_ref()
638 .filter(|badge| !badge.text.trim().is_empty())
639 {
640 if !first_section {
641 spans.push(Span::styled(
642 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
643 self.header_secondary_style(),
644 ));
645 }
646 let style = header_status_badge_style(badge, self.header_primary_style());
647 spans.push(Span::styled(badge.text.clone(), style));
648 first_section = false;
649 }
650
651 if let Some(badge) = self
652 .header_context
653 .pr_review
654 .as_ref()
655 .filter(|badge| !badge.text.trim().is_empty())
656 {
657 if !first_section {
658 spans.push(Span::styled(
659 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
660 self.header_secondary_style(),
661 ));
662 }
663 let style = header_status_badge_style(badge, self.header_primary_style());
664 spans.push(Span::styled(badge.text.clone(), style));
665 first_section = false;
666 }
667
668 for badge in self
669 .header_context
670 .subagent_badges
671 .iter()
672 .filter(|badge| !badge.text.trim().is_empty())
673 {
674 if !first_section {
675 spans.push(Span::styled(
676 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
677 self.header_secondary_style(),
678 ));
679 }
680 let text = if badge.full_background {
681 format!(" {} ", badge.text)
682 } else {
683 badge.text.clone()
684 };
685 spans.push(Span::styled(text, header_context_badge_style(badge)));
686 first_section = false;
687 }
688
689 for value in self.header_chain_values() {
690 if !first_section {
691 spans.push(Span::styled(
692 ui::HEADER_MODE_SECONDARY_SEPARATOR.to_owned(),
693 self.header_secondary_style(),
694 ));
695 }
696 spans.push(Span::styled(value, self.header_primary_style()));
697 first_section = false;
698 }
699
700 if spans.is_empty() {
701 spans.push(Span::raw(String::new()));
702 }
703
704 Line::from(spans)
705 }
706
707 fn header_highlights_line(&self) -> Option<Line<'static>> {
708 let mut spans = Vec::new();
709 let mut first_section = true;
710
711 for highlight in &self.header_context.highlights {
712 let title = highlight.title.trim();
713 let summary = self.header_highlight_summary(highlight);
714
715 if title.is_empty() && summary.is_none() {
716 continue;
717 }
718
719 if !first_section {
720 spans.push(Span::styled(
721 ui::HEADER_META_SEPARATOR.to_owned(),
722 self.header_secondary_style(),
723 ));
724 }
725
726 if !title.is_empty() {
727 let mut title_style = self.header_secondary_style();
728 title_style = title_style.add_modifier(Modifier::BOLD);
729 let mut title_text = title.to_owned();
730 if summary.is_some() {
731 title_text.push(':');
732 }
733 spans.push(Span::styled(title_text, title_style));
734 if summary.is_some() {
735 spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
736 }
737 }
738
739 if let Some(body) = summary {
740 spans.push(Span::styled(body, self.header_primary_style()));
741 }
742
743 first_section = false;
744 }
745
746 if spans.is_empty() {
747 None
748 } else {
749 Some(Line::from(spans))
750 }
751 }
752
753 fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
754 let entries: Vec<String> = highlight
755 .lines
756 .iter()
757 .map(|line| line.trim())
758 .filter(|line| !line.is_empty())
759 .map(|line| {
760 let stripped = line
761 .strip_prefix("- ")
762 .or_else(|| line.strip_prefix("• "))
763 .unwrap_or(line);
764 stripped.trim().to_owned()
765 })
766 .collect();
767
768 if entries.is_empty() {
769 return None;
770 }
771
772 Some(self.compact_highlight_entries(&entries))
773 }
774
775 fn compact_highlight_entries(&self, entries: &[String]) -> String {
776 let mut summary =
777 self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
778 if entries.len() > 1 {
779 let remaining = entries.len() - 1;
780 if !summary.is_empty() {
781 let _ = write!(summary, " (+{} more)", remaining);
782 } else {
783 summary = format!("(+{} more)", remaining);
784 }
785 }
786 summary
787 }
788
789 fn truncate_highlight_preview(&self, text: &str) -> String {
790 let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
791 if max == 0 {
792 return String::new();
793 }
794
795 let grapheme_count = text.graphemes(true).count();
796 if grapheme_count <= max {
797 return text.to_owned();
798 }
799
800 let mut truncated = String::new();
804 truncated.reserve(text.len().min(max * 4));
806 for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
807 truncated.push_str(grapheme);
808 }
809 truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
810 truncated
811 }
812
813 fn should_show_suggestions(&self) -> bool {
815 self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
817 }
818
819 pub(crate) fn header_suggestions_line(&self) -> Option<Line<'static>> {
821 let dim = self.header_secondary_style().add_modifier(Modifier::DIM);
822 let key = self.header_primary_style().add_modifier(Modifier::BOLD);
823 let label = self.header_secondary_style();
824 let dot = Span::styled(" · ", dim);
825
826 let mut spans = vec![
827 Span::styled("/help", key),
828 dot.clone(),
829 Span::styled("/model", key),
830 dot.clone(),
831 Span::styled("/effort", key),
832 dot.clone(),
833 Span::styled("/config", key),
834 dot.clone(),
835 Span::styled("/clear", key),
836 Span::styled(" │ ", dim),
837 Span::styled("↑↓", key),
838 Span::styled(" nav", label),
839 dot.clone(),
840 Span::styled("Tab", key),
841 Span::styled(" complete", label),
842 ];
843
844 if self.has_delegated_local_agents() {
845 spans.push(Span::styled(" │ ", dim));
846 spans.push(Span::styled("Alt+S", key));
847 spans.push(Span::styled(" agents", label));
848 spans.push(dot.clone());
849 spans.push(Span::styled("Ctrl+B", key));
850 spans.push(Span::styled(" background", label));
851 }
852
853 Some(Line::from(spans))
854 }
855
856 pub(crate) fn section_title_style(&self) -> Style {
857 let mut style = self
858 .styles
859 .default_style()
860 .add_modifier(Modifier::BOLD | Modifier::DIM);
861 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
862 style = style.fg(ratatui_color_from_ansi(primary));
863 }
864 style
865 }
866
867 fn header_primary_style(&self) -> Style {
868 let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
869 if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
870 style = style.fg(ratatui_color_from_ansi(primary));
871 }
872 style
873 }
874
875 pub(crate) fn header_secondary_style(&self) -> Style {
876 let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
877 if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
878 style = style.fg(ratatui_color_from_ansi(secondary));
879 }
880 style
881 }
882
883 fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
884 value.strip_prefix(prefix).unwrap_or(value)
885 }
886}