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
10fn 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
20fn 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
32fn 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
40fn section_header(name: &str) -> String {
42 let theme = current_theme();
43 theme.fg_key(ThemeKey::MdHeading, &format!("[{}]", name))
44}
45
46pub struct HeaderComponent {
50 expanded: bool,
51 cached_lines: Option<Vec<String>>,
52 context_files: Vec<String>,
54 skills: Vec<String>,
56 prompt_templates: Vec<String>,
58 extensions: Vec<String>,
60 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 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 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 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 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 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 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 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 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 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 lines.push(String::new());
296 lines.push(onboarding);
297 lines.push(String::new()); 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 return false;
325 }
326 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}