Skip to main content

rab/agent/ui/components/
header.rs

1use crossterm::event::KeyEvent;
2
3use crate::agent::ui::theme::ThemeKey;
4use crate::agent::ui::theme::current_theme;
5use crate::tui::Component;
6use crate::tui::keybindings;
7use crate::tui::keybindings::get_keybindings;
8const VERSION: &str = env!("CARGO_PKG_VERSION");
9
10/// Helper: get the display key text for an action (matches pi's keyText).
11fn key_text(action_id: &str) -> String {
12    let keys = keybindings::get_keybindings().get_keys(action_id);
13    if keys.is_empty() {
14        String::new()
15    } else {
16        keys[0].clone()
17    }
18}
19
20/// Format a key hint line: `<dim>key</dim><muted> description</muted>` (matches pi's keyHint).
21fn key_hint(action_id: &str, description: &str) -> String {
22    let kt = key_text(action_id);
23    if kt.is_empty() {
24        return String::new();
25    }
26    let theme = current_theme();
27    let key_part = theme.fg_key(ThemeKey::Dim, &kt);
28    let desc_part = theme.fg_key(ThemeKey::Muted, &format!(" {}", description));
29    format!("{}{}", key_part, desc_part)
30}
31
32/// Format a raw key hint: `<dim>raw_key</dim><muted> description</muted>` (matches pi's rawKeyHint).
33fn raw_key_hint(key: &str, description: &str) -> String {
34    let theme = current_theme();
35    let key_part = theme.fg_key(ThemeKey::Dim, key);
36    let desc_part = theme.fg_key(ThemeKey::Muted, &format!(" {}", description));
37    format!("{}{}", key_part, desc_part)
38}
39
40/// Format a resource section header like `[Context]` (matches pi's sectionHeader).
41fn section_header(name: &str) -> String {
42    let theme = current_theme();
43    theme.fg_key(ThemeKey::MdHeading, &format!("[{}]", name))
44}
45
46/// Header component matching pi's ExpandableText startup header.
47/// Shows logo, keybinding hints in compact/expanded modes, loaded resources,
48/// and onboarding text.
49pub struct HeaderComponent {
50    expanded: bool,
51    cached_lines: Option<Vec<String>>,
52    /// Context file paths (AGENTS.md / CLAUDE.md) loaded for the session.
53    context_files: Vec<String>,
54    /// Skill names loaded for the session.
55    skills: Vec<String>,
56    /// Prompt template command names (e.g. "/explain", "/review").
57    prompt_templates: Vec<String>,
58    /// Extension names loaded for the session.
59    extensions: Vec<String>,
60    /// Custom theme names loaded for the session.
61    themes: Vec<String>,
62}
63
64impl HeaderComponent {
65    pub fn new() -> Self {
66        Self {
67            expanded: false,
68            cached_lines: None,
69            context_files: Vec::new(),
70            skills: Vec::new(),
71            prompt_templates: Vec::new(),
72            extensions: Vec::new(),
73            themes: Vec::new(),
74        }
75    }
76
77    /// Create with initial expansion state (matching pi's getStartupExpansionState).
78    pub fn new_with_expanded(expanded: bool) -> Self {
79        Self {
80            expanded,
81            cached_lines: None,
82            context_files: Vec::new(),
83            skills: Vec::new(),
84            prompt_templates: Vec::new(),
85            extensions: Vec::new(),
86            themes: Vec::new(),
87        }
88    }
89
90    /// Set resource data for display in the header (pi-style loaded resources).
91    pub fn set_resource_data(
92        &mut self,
93        context_files: Vec<String>,
94        skills: Vec<String>,
95        prompt_templates: Vec<String>,
96        extensions: Vec<String>,
97        themes: Vec<String>,
98    ) {
99        self.context_files = context_files;
100        self.skills = skills;
101        self.prompt_templates = prompt_templates;
102        self.extensions = extensions;
103        self.themes = themes;
104        self.cached_lines = None;
105    }
106
107    /// Build collapsed resource sections (compact mode): "[Section]\n  item1, item2"
108    fn collapsed_resource_sections(&self) -> Vec<String> {
109        let mut lines = Vec::new();
110
111        if !self.context_files.is_empty() {
112            lines.push(String::new());
113            lines.push(section_header("Context"));
114            let theme = current_theme();
115            lines.push(theme.fg_key(
116                ThemeKey::Dim,
117                &format!("  {}", self.context_files.join(", ")),
118            ));
119        }
120
121        if !self.skills.is_empty() {
122            lines.push(String::new());
123            lines.push(section_header("Skills"));
124            let theme = current_theme();
125            lines.push(theme.fg_key(ThemeKey::Dim, &format!("  {}", self.skills.join(", "))));
126        }
127
128        if !self.prompt_templates.is_empty() {
129            lines.push(String::new());
130            lines.push(section_header("Prompts"));
131            let theme = current_theme();
132            lines.push(theme.fg_key(
133                ThemeKey::Dim,
134                &format!("  /{}", self.prompt_templates.join(", /")),
135            ));
136        }
137
138        if !self.extensions.is_empty() {
139            lines.push(String::new());
140            lines.push(section_header("Extensions"));
141            let theme = current_theme();
142            lines.push(theme.fg_key(ThemeKey::Dim, &format!("  {}", self.extensions.join(", "))));
143        }
144
145        if !self.themes.is_empty() {
146            lines.push(String::new());
147            lines.push(section_header("Themes"));
148            let theme = current_theme();
149            lines.push(theme.fg_key(ThemeKey::Dim, &format!("  {}", self.themes.join(", "))));
150        }
151
152        lines
153    }
154
155    /// Build expanded resource sections: "[Section]\n  item1\n  item2"
156    fn expanded_resource_sections(&self) -> Vec<String> {
157        let mut lines = Vec::new();
158
159        if !self.context_files.is_empty() {
160            lines.push(String::new());
161            lines.push(section_header("Context"));
162            let theme = current_theme();
163            for cf in &self.context_files {
164                lines.push(theme.fg_key(ThemeKey::Dim, &format!("  {}", cf)));
165            }
166        }
167
168        if !self.skills.is_empty() {
169            lines.push(String::new());
170            lines.push(section_header("Skills"));
171            let theme = current_theme();
172            for skill in &self.skills {
173                lines.push(theme.fg_key(ThemeKey::Dim, &format!("  {}", skill)));
174            }
175        }
176
177        if !self.prompt_templates.is_empty() {
178            lines.push(String::new());
179            lines.push(section_header("Prompts"));
180            let theme = current_theme();
181            for tmpl in &self.prompt_templates {
182                lines.push(theme.fg_key(ThemeKey::Dim, &format!("  /{}", tmpl)));
183            }
184        }
185
186        if !self.extensions.is_empty() {
187            lines.push(String::new());
188            lines.push(section_header("Extensions"));
189            let theme = current_theme();
190            for ext in &self.extensions {
191                lines.push(theme.fg_key(ThemeKey::Dim, &format!("  {}", ext)));
192            }
193        }
194
195        if !self.themes.is_empty() {
196            lines.push(String::new());
197            lines.push(section_header("Themes"));
198            let theme = current_theme();
199            for t in &self.themes {
200                lines.push(theme.fg_key(ThemeKey::Dim, &format!("  {}", t)));
201            }
202        }
203
204        lines
205    }
206
207    fn build_lines(&self, _width: usize) -> Vec<String> {
208        // Pi-style: leading space before logo (matching " pi v0.80.3")
209        let logo = {
210            let theme = current_theme();
211            format!(
212                " {}{}",
213                theme.bold(&theme.fg_key(ThemeKey::Accent, "rab")),
214                theme.fg_key(ThemeKey::Dim, &format!(" v{}", VERSION)),
215            )
216        };
217
218        // Main onboarding text (matches pi)
219        let onboarding = {
220            let theme = current_theme();
221            theme.fg(
222                "dim",
223                "rab can explain its own features and look up its docs. Ask it how to use or extend rab.",
224            )
225        };
226
227        let mut lines: Vec<String> = Vec::new();
228        lines.push(logo);
229
230        if self.expanded {
231            // ── Expanded: each hint on its own line ──
232            lines.push(key_hint("app.interrupt", "to interrupt"));
233            lines.push(key_hint("app.clear", "to clear"));
234            lines.push(raw_key_hint(
235                &format!("{} twice", key_text("app.clear")),
236                "to exit",
237            ));
238            lines.push(key_hint("app.exit", "to exit (empty)"));
239            lines.push(key_hint("app.suspend", "to suspend"));
240            lines.push(key_hint("tui.editor.deleteToLineEnd", "to delete to end"));
241            lines.push(key_hint("app.thinking.cycle", "to cycle thinking level"));
242            lines.push(raw_key_hint(
243                &format!(
244                    "{}/{}",
245                    key_text("app.model.cycleForward"),
246                    key_text("app.model.cycleBackward")
247                ),
248                "to cycle models",
249            ));
250            lines.push(key_hint("app.model.select", "to select model"));
251            lines.push(key_hint("app.tools.expand", "to expand tools"));
252            lines.push(key_hint("app.thinking.toggle", "to expand thinking"));
253            lines.push(key_hint("app.editor.external", "for external editor"));
254            lines.push(raw_key_hint("/", "for commands"));
255            lines.push(raw_key_hint("!", "to run bash"));
256            lines.push(raw_key_hint("!!", "to run bash (no context)"));
257            lines.push(key_hint("app.message.followUp", "to queue follow-up"));
258            lines.push(key_hint(
259                "app.message.dequeue",
260                "to edit all queued messages",
261            ));
262            lines.push(raw_key_hint("drop files", "to attach"));
263        } else {
264            // ── Compact: hints joined by " · " ──
265            let parts = [
266                key_hint("app.interrupt", "interrupt"),
267                raw_key_hint(
268                    &format!("{}/{}", key_text("app.clear"), key_text("app.exit")),
269                    "clear/exit",
270                ),
271                raw_key_hint("/", "commands"),
272                raw_key_hint("!", "bash"),
273                key_hint("app.tools.expand", "more"),
274            ];
275            let separator = {
276                let theme = current_theme();
277                theme.fg_key(ThemeKey::Muted, " · ")
278            };
279            lines.push(parts.join(&separator));
280
281            // Compact onboarding
282            lines.push({
283                let theme = current_theme();
284                theme.fg(
285                    "dim",
286                    &format!(
287                        "Press {} to show full startup help and loaded resources.",
288                        key_text("app.tools.expand"),
289                    ),
290                )
291            });
292        }
293
294        // Pi ordering: onboarding BEFORE resources (with blank line after)
295        lines.push(String::new());
296        lines.push(onboarding);
297        lines.push(String::new()); // extra blank to match pi's spacing before resource sections
298
299        // Resource sections (pi-style: shown in both modes)
300        if self.expanded {
301            lines.extend(self.expanded_resource_sections());
302        } else {
303            lines.extend(self.collapsed_resource_sections());
304        }
305
306        lines
307    }
308}
309
310impl Default for HeaderComponent {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316impl Component for HeaderComponent {
317    fn handle_input(&mut self, key: &KeyEvent) -> bool {
318        let kb = get_keybindings();
319        if kb.matches(key, keybindings::ACTION_APP_TOOLS_EXPAND) {
320            self.expanded = !self.expanded;
321            self.cached_lines = None;
322            // Don't consume - let the app-level handler also process Ctrl+O
323            // so tool messages and global state (tools_expanded) stay in sync.
324            return false;
325        }
326        // Escape collapses expanded header
327        if self.expanded && kb.matches(key, keybindings::ACTION_APP_ESCAPE) {
328            self.expanded = false;
329            self.cached_lines = None;
330            return true;
331        }
332        false
333    }
334
335    fn set_expanded(&mut self, expanded: bool) {
336        self.expanded = expanded;
337        self.cached_lines = None;
338    }
339
340    fn render(&mut self, width: usize) -> Vec<String> {
341        if let Some(ref cached) = self.cached_lines {
342            return cached.clone();
343        }
344        let lines = self.build_lines(width);
345        self.cached_lines = Some(lines.clone());
346        lines
347    }
348
349    fn invalidate(&mut self) {
350        self.cached_lines = None;
351    }
352}