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