Skip to main content

vtcode_ui/tui/core_tui/session/
header.rs

1use std::fmt::Write;
2use std::sync::Arc;
3
4use ratatui::{
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Block, Paragraph, Wrap},
8};
9use unicode_segmentation::UnicodeSegmentation;
10
11use crate::tui::config::constants::ui;
12use crate::tui::core_tui::blocked_status;
13use crate::tui::core_tui::style::convert_style;
14
15use super::super::types::{InlineHeaderContext, InlineHeaderHighlight};
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 ultrafast_badge_style(colors_enabled: bool, safe_colors_only: bool, bold_is_bright: bool) -> Style {
21    let mut style = Style::default().remove_modifier(Modifier::DIM | Modifier::REVERSED);
22    style = if !colors_enabled {
23        style.fg(Color::Reset).bg(Color::Reset).add_modifier(Modifier::REVERSED)
24    } else if safe_colors_only {
25        style.fg(Color::Black).bg(Color::Yellow)
26    } else {
27        // Black text on the premium #D99A4E fill exceeds WCAG AAA (7:1).
28        style.fg(Color::Rgb(0, 0, 0)).bg(Color::Rgb(0xD9, 0x9A, 0x4E))
29    };
30    if bold_is_bright {
31        style.remove_modifier(Modifier::BOLD)
32    } else {
33        style.add_modifier(Modifier::BOLD)
34    }
35}
36
37fn clean_reasoning_text(text: &str) -> String {
38    vtcode_commons::formatting::clean_reasoning_text(text)
39}
40
41fn capitalize_first_letter(s: &str) -> String {
42    let mut chars = s.chars();
43    match chars.next() {
44        None => String::new(),
45        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
46    }
47}
48
49fn format_model_summary_label(model: &str) -> String {
50    model.split('-').map(capitalize_first_letter).collect::<Vec<_>>().join("-")
51}
52
53pub(crate) fn primary_agent_header_label(name: Option<&str>) -> String {
54    let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
55        return "Build".to_string();
56    };
57    format_model_summary_label(name)
58}
59
60fn compact_context_window_label(context_window_size: usize) -> String {
61    if context_window_size >= 1_000_000 {
62        format!("{}M", context_window_size / 1_000_000)
63    } else if context_window_size >= 1_000 {
64        format!("{}K", context_window_size / 1_000)
65    } else {
66        context_window_size.to_string()
67    }
68}
69
70fn line_is_empty(spans: &[Span<'static>]) -> bool {
71    spans.len() == 1 && spans.first().is_some_and(|span| span.content.is_empty())
72}
73
74impl Session {
75    pub(crate) fn header_lines(&mut self) -> Arc<Vec<Line<'static>>> {
76        if let Some(cached) = &self.header_lines_cache {
77            return Arc::clone(cached);
78        }
79
80        let lines = if self.appearance.hide_header {
81            vec![self.header_block_title_for_width(self.transcript_width)]
82        } else {
83            vec![self.header_compact_line()]
84        };
85        let arc = Arc::new(lines);
86        self.header_lines_cache = Some(Arc::clone(&arc));
87        arc
88    }
89
90    /// Cached border-block title (rebuilt only when the header cache drops).
91    pub(crate) fn header_block_title_cached(&mut self) -> Line<'static> {
92        if let Some(title) = &self.header_block_title_cache {
93            return title.clone();
94        }
95        let title = self.header_block_title();
96        self.header_block_title_cache = Some(title.clone());
97        title
98    }
99
100    pub(crate) fn header_height_from_lines(&mut self, width: u16, lines: &[Line<'static>]) -> u16 {
101        if self.appearance.hide_header {
102            return 1;
103        }
104
105        if width == 0 {
106            return self.header_rows.max(ui::INLINE_HEADER_HEIGHT);
107        }
108
109        if let Some(&height) = self.header_height_cache.get(&width) {
110            return height;
111        }
112
113        let paragraph = self.build_header_paragraph(lines);
114        let measured = paragraph.line_count(width);
115        let resolved = u16::try_from(measured).unwrap_or(u16::MAX);
116        // Limit to max 3 lines to accommodate suggestions
117        let resolved = resolved.clamp(ui::INLINE_HEADER_HEIGHT, 3);
118        self.header_height_cache.insert(width, resolved);
119        resolved
120    }
121
122    pub(crate) fn build_header_paragraph(&self, lines: &[Line<'static>]) -> Paragraph<'static> {
123        let text_style = self.header_primary_style().add_modifier(Modifier::DIM);
124
125        if self.appearance.hide_header {
126            return Paragraph::new(lines.to_vec()).style(text_style).wrap(Wrap { trim: true });
127        }
128
129        let mut border_style = Style::default();
130        if let Some(accent) = self.theme.tool_accent.or(self.theme.primary).or(self.theme.foreground) {
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.as_slice())
150    }
151
152    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 =
171            format!(" {}{}{}", ui::HEADER_VERSION_LEFT_DELIMITER, version.trim(), ui::HEADER_VERSION_RIGHT_DELIMITER);
172
173        let prompt_style = self.section_title_style();
174        let version_style = self.header_secondary_style().add_modifier(Modifier::DIM);
175
176        let mut spans = vec![
177            Span::styled(prompt, prompt_style),
178            Span::styled(version_text, version_style),
179        ];
180
181        if self.appearance.hide_header && width > 0 {
182            let right_spans = self.header_compact_right_spans();
183            if right_spans.is_empty() {
184                return Line::from(spans);
185            }
186
187            let left_width = spans.iter().map(Span::width).sum::<usize>();
188            let summary_width = right_spans.iter().map(Span::width).sum::<usize>();
189            let available_width = usize::from(width);
190            let spacer_width = available_width.saturating_sub(left_width.saturating_add(summary_width)).max(1);
191            spans.push(Span::raw(" ".repeat(spacer_width)));
192            spans.extend(right_spans);
193        }
194
195        let line = Line::from(spans);
196        if width > 0 {
197            truncate_line_with_ellipsis_if_overflow(line, usize::from(width))
198        } else {
199            line
200        }
201    }
202
203    fn header_compact_right_spans(&self) -> Vec<Span<'static>> {
204        let mut spans = Vec::new();
205        let agent_label = primary_agent_header_label(self.header_context.primary_agent.as_deref());
206        let model_summary_spans = self.header_compact_model_summary_spans();
207        if !agent_label.trim().is_empty() {
208            let fallback = self.theme.primary.map(ratatui_color_from_ansi).unwrap_or(Color::LightMagenta);
209            let agent_style =
210                super::super::style::agent_color_style(self.header_context.primary_agent_color.as_deref(), fallback);
211            spans.push(Span::styled(agent_label, agent_style));
212        }
213
214        if !spans.is_empty() && !model_summary_spans.is_empty() {
215            spans.push(Span::styled(" · ".to_owned(), self.header_secondary_style()));
216        }
217        spans.extend(model_summary_spans);
218
219        spans
220    }
221
222    fn header_compact_model_summary_spans(&self) -> Vec<Span<'static>> {
223        let provider = self.header_provider_short_value();
224        let model = self.header_model_short_value();
225        let reasoning = self.header_reasoning_short_value();
226        let mut spans = Vec::new();
227        let mut provider_model_parts = Vec::new();
228
229        match (
230            !provider.is_empty() && !provider.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
231            !model.is_empty() && !model.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER),
232        ) {
233            (true, true) => provider_model_parts.push(format!(
234                "{} {}",
235                capitalize_first_letter(&provider),
236                format_model_summary_label(&model)
237            )),
238            (true, false) => provider_model_parts.push(capitalize_first_letter(&provider)),
239            (false, true) => provider_model_parts.push(format_model_summary_label(&model)),
240            (false, false) => {}
241        }
242
243        if let Some(summary) = (!provider_model_parts.is_empty()).then(|| provider_model_parts.join(" ")) {
244            spans.push(Span::styled(summary, self.header_secondary_style().add_modifier(Modifier::DIM)));
245        }
246
247        if !reasoning.is_empty() && !reasoning.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER) {
248            if !spans.is_empty() {
249                spans.push(Span::styled(" · ".to_owned(), self.header_secondary_style()));
250            }
251            let value_style = self.header_secondary_style().add_modifier(Modifier::ITALIC);
252            spans.push(Span::styled(reasoning, value_style));
253        }
254
255        if let Some(tier) = self.header_service_tier_short_value() {
256            if !spans.is_empty() {
257                spans.push(Span::styled(" · ".to_owned(), self.header_secondary_style()));
258            }
259            spans.push(self.header_service_tier_span(&tier));
260        }
261
262        spans
263    }
264
265    pub(crate) fn header_title_line(&self) -> Line<'static> {
266        // First line: badge-style provider + model + reasoning summary
267        let mut spans = Vec::new();
268
269        let provider = self.header_provider_short_value();
270        let model = self.header_model_short_value();
271        let reasoning = self.header_reasoning_short_value();
272
273        if !provider.is_empty() {
274            let capitalized_provider = capitalize_first_letter(&provider);
275            let mut style = self.header_primary_style();
276            style = style.add_modifier(Modifier::BOLD);
277            spans.push(Span::styled(capitalized_provider, style));
278        }
279
280        if !model.is_empty() {
281            if !spans.is_empty() {
282                spans.push(Span::raw(" "));
283            }
284            let mut style = self.header_primary_style();
285            style = style.add_modifier(Modifier::ITALIC);
286            spans.push(Span::styled(model, style));
287        }
288
289        if !reasoning.is_empty() {
290            if !spans.is_empty() {
291                spans.push(Span::raw(" "));
292            }
293            let mut style = self.header_secondary_style();
294            style = style.add_modifier(Modifier::ITALIC | Modifier::DIM);
295
296            if let Some(stage) = &self.header_context.reasoning_stage {
297                let mut stage_style = style;
298                stage_style = stage_style.remove_modifier(Modifier::DIM).add_modifier(Modifier::BOLD);
299                spans.push(Span::styled(format!("[{stage}]"), stage_style));
300                spans.push(Span::raw(" "));
301            }
302
303            spans.push(Span::styled(reasoning.to_string(), style));
304        }
305
306        if let Some(tier) = self.header_service_tier_short_value() {
307            if !spans.is_empty() {
308                spans.push(Span::raw(" "));
309            }
310            spans.push(self.header_service_tier_span(&tier));
311        }
312
313        if spans.is_empty() {
314            spans.push(Span::raw(String::new()));
315        }
316
317        Line::from(spans)
318    }
319
320    fn header_compact_line(&self) -> Line<'static> {
321        let mut spans = self.header_title_line().spans;
322        if line_is_empty(&spans) {
323            spans.clear();
324        }
325
326        let mut meta_spans = self.header_meta_line().spans;
327        if line_is_empty(&meta_spans) {
328            meta_spans.clear();
329        }
330
331        let mut tail_spans = if self.should_show_suggestions() {
332            self.header_suggestions_line()
333        } else {
334            self.header_highlights_line()
335        }
336        .map(|line| line.spans)
337        .unwrap_or_default();
338
339        if line_is_empty(&tail_spans) {
340            tail_spans.clear();
341        }
342
343        let separator_style = self.header_secondary_style();
344        let separator = Span::styled(ui::HEADER_PRIMARY_SEPARATOR.to_owned(), separator_style);
345
346        let mut append_section = |section: &mut Vec<Span<'static>>| {
347            if section.is_empty() {
348                return;
349            }
350            if !spans.is_empty() {
351                spans.push(separator.clone());
352            }
353            spans.append(section);
354        };
355
356        append_section(&mut meta_spans);
357        append_section(&mut tail_spans);
358
359        if spans.is_empty() {
360            spans.push(Span::raw(String::new()));
361        }
362
363        Line::from(spans)
364    }
365
366    fn header_provider_value(&self) -> String {
367        let trimmed = self.header_context.provider.trim();
368        if trimmed.is_empty() {
369            InlineHeaderContext::default().provider
370        } else {
371            self.header_context.provider.clone()
372        }
373    }
374
375    fn header_model_value(&self) -> String {
376        let trimmed = self.header_context.model.trim();
377        if trimmed.is_empty() {
378            InlineHeaderContext::default().model
379        } else {
380            self.header_context.model.clone()
381        }
382    }
383
384    fn header_reasoning_value(&self) -> Option<String> {
385        let raw_reasoning = &self.header_context.reasoning;
386        let cleaned = clean_reasoning_text(raw_reasoning);
387        let trimmed = cleaned.trim();
388        let value = if trimmed.is_empty() {
389            InlineHeaderContext::default().reasoning
390        } else {
391            cleaned
392        };
393        if value.trim().is_empty() { None } else { Some(value) }
394    }
395
396    fn header_provider_short_value(&self) -> String {
397        let value = self.header_provider_value();
398        Self::strip_prefix(&value, ui::HEADER_PROVIDER_PREFIX).trim().to_owned()
399    }
400
401    fn header_model_short_value(&self) -> String {
402        let value = self.header_model_value();
403        let model = Self::strip_prefix(&value, ui::HEADER_MODEL_PREFIX).trim().to_owned();
404
405        match self.header_context.context_window_size {
406            Some(context_window_size) if context_window_size > 0 => {
407                format!("{} ({})", model, compact_context_window_label(context_window_size))
408            }
409            _ => model,
410        }
411    }
412
413    fn header_reasoning_short_value(&self) -> String {
414        let value = self.header_reasoning_value().unwrap_or_default();
415        Self::strip_prefix(&value, ui::HEADER_REASONING_PREFIX).trim().to_owned()
416    }
417
418    fn header_service_tier_short_value(&self) -> Option<String> {
419        let raw = self.header_context.service_tier.as_deref()?;
420        let tier = Self::strip_prefix(raw, ui::HEADER_SERVICE_TIER_PREFIX).trim();
421        (!tier.is_empty() && !tier.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER)).then(|| tier.to_owned())
422    }
423
424    fn header_service_tier_span(&self, tier: &str) -> Span<'static> {
425        if tier.eq_ignore_ascii_case("ultrafast") {
426            Span::styled(
427                " ULTRAFAST ",
428                ultrafast_badge_style(
429                    vtcode_commons::color_policy::current_color_output_policy().enabled,
430                    crate::theme::is_safe_colors_only(),
431                    crate::theme::is_bold_bright_mode(),
432                ),
433            )
434        } else {
435            Span::styled(capitalize_first_letter(tier), self.header_secondary_style())
436        }
437    }
438
439    pub fn header_chain_values(&self) -> Vec<String> {
440        let mut values = Vec::new();
441
442        for value in [
443            &self.header_context.tools,
444            &self.header_context.git,
445            &self.header_context.mcp,
446        ] {
447            let trimmed = value.trim();
448            if trimmed.is_empty() {
449                continue;
450            }
451
452            if trimmed.starts_with(ui::HEADER_TOOLS_PREFIX) || trimmed.starts_with(ui::HEADER_GIT_PREFIX) {
453                continue;
454            }
455
456            if let Some(body) = trimmed.strip_prefix(ui::HEADER_MCP_PREFIX) {
457                let body = body.trim();
458                if body.is_empty() || body.eq_ignore_ascii_case(ui::HEADER_UNKNOWN_PLACEHOLDER) {
459                    continue;
460                }
461                values.push(format!("MCP: {body}"));
462                continue;
463            }
464
465            values.push(trimmed.to_owned());
466        }
467
468        values
469    }
470
471    pub(crate) fn header_meta_line(&self) -> Line<'static> {
472        let mut spans = Vec::new();
473
474        let mut first_section = true;
475        let separator_style = self.header_secondary_style();
476
477        let push_badge = |spans: &mut Vec<Span<'static>>, text: String, style: Style, first: &mut bool| {
478            if !*first {
479                spans.push(Span::styled(ui::HEADER_SECONDARY_SEPARATOR.to_owned(), separator_style));
480            }
481            spans.push(Span::styled(text, style));
482            *first = false;
483        };
484
485        let agent_badge_color = self
486            .theme
487            .primary
488            .or(self.theme.foreground)
489            .map(ratatui_color_from_ansi)
490            .unwrap_or(Color::LightMagenta);
491        let agent_style = Style::default().fg(agent_badge_color).add_modifier(Modifier::BOLD);
492        push_badge(
493            &mut spans,
494            primary_agent_header_label(self.header_context.primary_agent.as_deref()),
495            agent_style,
496            &mut first_section,
497        );
498
499        // Show trust level badge
500        let trust_value = self.header_context.workspace_trust.to_lowercase();
501        if trust_value.contains("full auto") || trust_value.contains("full_auto") {
502            let auto_badge_color = self
503                .theme
504                .tool_accent
505                .or(self.theme.primary)
506                .or(self.theme.foreground)
507                .map(ratatui_color_from_ansi)
508                .unwrap_or(Color::LightCyan);
509            let badge_style = Style::default().fg(auto_badge_color).add_modifier(Modifier::BOLD);
510            push_badge(&mut spans, "Full-auto".to_string(), badge_style, &mut first_section);
511        } else if trust_value.contains("tools policy") || trust_value.contains("tools_policy") {
512            let safe_badge_color = self
513                .theme
514                .primary
515                .or(self.theme.foreground)
516                .map(ratatui_color_from_ansi)
517                .unwrap_or(Color::LightGreen);
518            let badge_style = Style::default().fg(safe_badge_color).add_modifier(Modifier::BOLD);
519            push_badge(&mut spans, "Safe".to_string(), badge_style, &mut first_section);
520        }
521
522        // Show active subagent badge (first one, matching input block behavior)
523        if let Some(badge) = self.header_context.subagent_badges.first() {
524            let mut badge_style = ratatui_style_from_inline(&badge.style, self.theme.foreground);
525            if badge.full_background {
526                badge_style = badge_style.add_modifier(Modifier::BOLD);
527            }
528            let hidden = self.header_context.subagent_badges.len().saturating_sub(1);
529            let label = if hidden == 0 {
530                badge.text.clone()
531            } else {
532                format!("{} +{}", badge.text, hidden)
533            };
534            push_badge(&mut spans, format!(" {label} "), badge_style, &mut first_section);
535        }
536
537        // Persistent background-work badge. The bottom-line background copy is
538        // idle-only (loading moves to the transcript progress row), so the
539        // header carries the always-visible count with short static text that
540        // never shimmers or changes width per-second.
541        if let Some(badge) = self.background_header_badge_text() {
542            let style = if self.has_background_activity() {
543                // Live work stays prominent: undimmed primary bold, unlike the
544                // dimmed secondary used for retained history below.
545                self.header_primary_style()
546                    .remove_modifier(Modifier::DIM)
547                    .add_modifier(Modifier::BOLD)
548            } else {
549                self.header_secondary_style().add_modifier(Modifier::DIM)
550            };
551            push_badge(&mut spans, badge, style, &mut first_section);
552        }
553
554        // Show blocked/recovery badge so a stalled turn is visible in the header,
555        // not only in the footer hint. Uses ActivityState plus the blocked
556        // status needles shared with the footer hint (see blocked_status): the
557        // runloop flags blocked turns through status text, not a dedicated flag.
558        let is_blocked_header = matches!(
559            self.activity_state,
560            vtcode_commons::ui_protocol::ActivityState::Blocked | vtcode_commons::ui_protocol::ActivityState::Recovery
561        ) || self
562            .input_status_right
563            .as_deref()
564            .is_some_and(blocked_status::right_status_is_blocked)
565            || self
566                .input_status_left
567                .as_deref()
568                .is_some_and(blocked_status::left_status_is_blocked);
569        if is_blocked_header {
570            // Use the active theme's contrast-validated alert token instead of a
571            // hardcoded color so the badge adapts to the selected theme. The
572            // token carries bold unless the bold-is-bright accessibility mode
573            // suppresses it.
574            let blocked_inline = convert_style(crate::theme::active_styles().error);
575            let blocked_style = ratatui_style_from_inline(&blocked_inline, self.theme.foreground);
576            push_badge(&mut spans, "Blocked".to_string(), blocked_style, &mut first_section);
577        }
578
579        if spans.is_empty() {
580            spans.push(Span::raw(String::new()));
581        }
582
583        Line::from(spans)
584    }
585
586    fn header_highlights_line(&self) -> Option<Line<'static>> {
587        let mut spans = Vec::new();
588        let mut first_section = true;
589
590        for highlight in &self.header_context.highlights {
591            let title = highlight.title.trim();
592            let summary = self.header_highlight_summary(highlight);
593
594            if title.is_empty() && summary.is_none() {
595                continue;
596            }
597
598            if !first_section {
599                spans.push(Span::styled(ui::HEADER_META_SEPARATOR.to_owned(), self.header_secondary_style()));
600            }
601
602            if !title.is_empty() {
603                let mut title_style = self.header_secondary_style();
604                title_style = title_style.add_modifier(Modifier::BOLD);
605                let mut title_text = title.to_owned();
606                if summary.is_some() {
607                    title_text.push(':');
608                }
609                spans.push(Span::styled(title_text, title_style));
610                if summary.is_some() {
611                    spans.push(Span::styled(" ".to_owned(), self.header_secondary_style()));
612                }
613            }
614
615            if let Some(body) = summary {
616                spans.push(Span::styled(body, self.header_primary_style()));
617            }
618
619            first_section = false;
620        }
621
622        if spans.is_empty() {
623            None
624        } else {
625            Some(Line::from(spans))
626        }
627    }
628
629    fn header_highlight_summary(&self, highlight: &InlineHeaderHighlight) -> Option<String> {
630        let entries: Vec<String> = highlight
631            .lines
632            .iter()
633            .map(|line| line.trim())
634            .filter(|line| !line.is_empty())
635            .map(|line| {
636                let stripped = line.strip_prefix("- ").or_else(|| line.strip_prefix("• ")).unwrap_or(line);
637                stripped.trim().to_owned()
638            })
639            .collect();
640
641        if entries.is_empty() {
642            return None;
643        }
644
645        Some(self.compact_highlight_entries(&entries))
646    }
647
648    fn compact_highlight_entries(&self, entries: &[String]) -> String {
649        let mut summary = self.truncate_highlight_preview(entries.first().map(String::as_str).unwrap_or(""));
650        if entries.len() > 1 {
651            let remaining = entries.len() - 1;
652            if !summary.is_empty() {
653                let _ = write!(summary, " (+{remaining} more)");
654            } else {
655                summary = format!("(+{remaining} more)");
656            }
657        }
658        summary
659    }
660
661    fn truncate_highlight_preview(&self, text: &str) -> String {
662        let max = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
663        if max == 0 {
664            return String::new();
665        }
666
667        let grapheme_count = text.graphemes(true).count();
668        if grapheme_count <= max {
669            return text.to_owned();
670        }
671
672        // Optimization: return early if string is short (already checked above with grapheme count)
673        // This is safe because count() <= max means it doesn't need truncation.
674
675        let mut truncated = String::new();
676        // pre-allocate capacity
677        truncated.reserve(text.len().min(max * 4));
678        for grapheme in text.graphemes(true).take(max.saturating_sub(1)) {
679            truncated.push_str(grapheme);
680        }
681        truncated.push_str(ui::INLINE_PREVIEW_ELLIPSIS);
682        truncated
683    }
684
685    /// Determine if suggestions should be shown in the header
686    fn should_show_suggestions(&self) -> bool {
687        // Show suggestions when input is empty or starts with /
688        self.input_manager.content().is_empty() || self.input_manager.content().starts_with('/')
689    }
690
691    /// Generate header line with slash command and keyboard shortcut suggestions
692    pub(crate) fn header_suggestions_line(&self) -> Option<Line<'static>> {
693        let dim = self.header_secondary_style().add_modifier(Modifier::DIM);
694        let key = self.header_primary_style().add_modifier(Modifier::BOLD);
695        let label = self.header_secondary_style();
696        let dot = Span::styled(" · ", dim);
697
698        let mut spans = vec![
699            Span::styled("/help", key),
700            dot.clone(),
701            Span::styled("/model", key),
702            dot.clone(),
703            Span::styled("/effort", key),
704            dot.clone(),
705            Span::styled("/config", key),
706            dot.clone(),
707            Span::styled("/clear", key),
708            Span::styled("  │  ", dim),
709            Span::styled("↑↓", key),
710            Span::styled(" nav", label),
711            dot.clone(),
712            Span::styled("Tab", key),
713            Span::styled(" complete", label),
714        ];
715
716        if self.has_local_agents() {
717            spans.push(Span::styled("  │  ", dim));
718            spans.push(Span::styled("Alt+S", key));
719            spans.push(Span::styled(" agents", label));
720            spans.push(dot.clone());
721            spans.push(Span::styled(self.background_shortcut_label().to_owned(), key));
722            spans.push(Span::styled(" background", label));
723        } else if self.has_active_foreground_pty() {
724            spans.push(Span::styled("  │  ", dim));
725            spans.push(Span::styled(self.background_shortcut_label().to_owned(), key));
726            spans.push(Span::styled(" background", label));
727        }
728
729        Some(Line::from(spans))
730    }
731
732    pub(crate) fn section_title_style(&self) -> Style {
733        let mut style = self.styles.default_style().add_modifier(Modifier::BOLD | Modifier::DIM);
734        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
735            style = style.fg(ratatui_color_from_ansi(primary));
736        }
737        style
738    }
739
740    pub(crate) fn header_primary_style(&self) -> Style {
741        let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
742        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
743            style = style.fg(ratatui_color_from_ansi(primary));
744        }
745        style
746    }
747
748    pub(crate) fn header_secondary_style(&self) -> Style {
749        let mut style = self.styles.default_style().add_modifier(Modifier::DIM);
750        if let Some(secondary) = self.theme.secondary.or(self.theme.foreground) {
751            style = style.fg(ratatui_color_from_ansi(secondary));
752        }
753        style
754    }
755
756    fn strip_prefix<'a>(value: &'a str, prefix: &str) -> &'a str {
757        value.strip_prefix(prefix).unwrap_or(value)
758    }
759}
760
761#[cfg(test)]
762mod badge_tests {
763    use super::*;
764
765    #[test]
766    fn ultrafast_badge_color_policy_and_bold_is_bright() {
767        for bold_is_bright in [false, true] {
768            for (colors_enabled, safe_colors_only, foreground, background, reversed) in [
769                (true, false, Color::Rgb(0, 0, 0), Color::Rgb(0xD9, 0x9A, 0x4E), false),
770                (true, true, Color::Black, Color::Yellow, false),
771                (false, false, Color::Reset, Color::Reset, true),
772                (false, true, Color::Reset, Color::Reset, true),
773            ] {
774                let style = ultrafast_badge_style(colors_enabled, safe_colors_only, bold_is_bright);
775                let inherited = Style::default()
776                    .add_modifier(Modifier::DIM | Modifier::BOLD | Modifier::REVERSED)
777                    .patch(style);
778                assert_eq!(inherited.fg, Some(foreground));
779                assert_eq!(inherited.bg, Some(background));
780                assert_eq!(inherited.add_modifier.contains(Modifier::REVERSED), reversed);
781                assert_eq!(inherited.add_modifier.contains(Modifier::BOLD), !bold_is_bright);
782                assert!(!inherited.add_modifier.contains(Modifier::DIM));
783            }
784        }
785    }
786}