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/// Header component matching pi's ExpandableText startup header.
41/// Shows logo, keybinding hints in compact/expanded modes, and onboarding text.
42pub struct HeaderComponent {
43    expanded: bool,
44    cached_lines: Option<Vec<String>>,
45}
46
47impl HeaderComponent {
48    pub fn new() -> Self {
49        Self {
50            expanded: false,
51            cached_lines: None,
52        }
53    }
54
55    fn build_lines(&self, _width: usize) -> Vec<String> {
56        let logo = {
57            let theme = current_theme();
58            format!(
59                "{}{}",
60                theme.bold(&theme.fg_key(ThemeKey::Accent, "rab")),
61                theme.fg_key(ThemeKey::Dim, &format!(" v{}", VERSION)),
62            )
63        };
64
65        if self.expanded {
66            // Expanded: full keybinding hints (matching pi's expandedInstructions)
67            let mut lines: Vec<String> = Vec::new();
68            lines.push(logo);
69            lines.push(String::new());
70
71            lines.push(key_hint("app.interrupt", "to interrupt"));
72            lines.push(key_hint("app.clear", "to clear"));
73            lines.push(raw_key_hint(
74                &format!("{} twice", key_text("app.clear")),
75                "to exit",
76            ));
77            lines.push(key_hint("app.exit", "to exit (empty)"));
78            lines.push(key_hint("app.suspend", "to suspend"));
79            lines.push(key_hint("tui.editor.deleteToLineEnd", "to delete to end"));
80            lines.push(key_hint("app.thinking.cycle", "to cycle thinking level"));
81            lines.push(raw_key_hint(
82                &format!(
83                    "{}/{}",
84                    key_text("app.model.cycleForward"),
85                    key_text("app.model.cycleBackward")
86                ),
87                "to cycle models",
88            ));
89            lines.push(key_hint("app.model.select", "to select model"));
90            lines.push(key_hint("app.tools.expand", "to expand tools"));
91            lines.push(key_hint("app.thinking.toggle", "to expand thinking"));
92            lines.push(key_hint("app.editor.external", "for external editor"));
93            lines.push(raw_key_hint("/", "for commands"));
94            lines.push(raw_key_hint("!", "to run bash"));
95            lines.push(raw_key_hint("!!", "to run bash (no context)"));
96            lines.push(key_hint("app.message.followUp", "to queue follow-up"));
97            lines.push(key_hint(
98                "app.message.dequeue",
99                "to edit all queued messages",
100            ));
101            lines.push(raw_key_hint("drop files", "to attach"));
102
103            lines
104        } else {
105            // Compact: single-line key hints joined by " · " (matching pi's compactInstructions)
106            let parts = [
107                key_hint("app.interrupt", "interrupt"),
108                raw_key_hint(
109                    &format!("{}/{}", key_text("app.clear"), key_text("app.exit")),
110                    "clear/exit",
111                ),
112                raw_key_hint("/", "commands"),
113                raw_key_hint("!", "bash"),
114                key_hint("app.tools.expand", "more"),
115            ];
116            let separator = {
117                let theme = current_theme();
118                theme.fg_key(ThemeKey::Muted, " · ")
119            };
120            let compact_line = parts.join(&separator);
121
122            let compact_onboarding = {
123                let theme = current_theme();
124                theme.fg(
125                    "dim",
126                    &format!(
127                        "Press {} to show full startup help and loaded resources.",
128                        key_text("app.tools.expand"),
129                    ),
130                )
131            };
132
133            vec![logo, compact_line, String::new(), compact_onboarding]
134        }
135    }
136}
137
138impl Default for HeaderComponent {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144impl Component for HeaderComponent {
145    fn handle_input(&mut self, key: &KeyEvent) -> bool {
146        let kb = get_keybindings();
147        if kb.matches(key, keybindings::ACTION_APP_TOOLS_EXPAND) {
148            self.expanded = !self.expanded;
149            self.cached_lines = None;
150            // Don't consume - let the app-level handler also process Ctrl+O
151            // so tool messages and global state (tools_expanded) stay in sync.
152            return false;
153        }
154        // Escape collapses expanded header
155        if self.expanded && kb.matches(key, keybindings::ACTION_APP_ESCAPE) {
156            self.expanded = false;
157            self.cached_lines = None;
158            return true;
159        }
160        false
161    }
162
163    fn set_expanded(&mut self, expanded: bool) {
164        self.expanded = expanded;
165        self.cached_lines = None;
166    }
167
168    fn render(&mut self, width: usize) -> Vec<String> {
169        if let Some(ref cached) = self.cached_lines {
170            return cached.clone();
171        }
172        let lines = self.build_lines(width);
173        self.cached_lines = Some(lines.clone());
174        lines
175    }
176
177    fn invalidate(&mut self) {
178        self.cached_lines = None;
179    }
180}