1use ratatui::layout::Rect;
13use ratatui::prelude::*;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, Borders, Clear, Paragraph};
17
18use crate::tui::app::App;
19use crate::tui::commands::CommandSpec;
20
21pub const MAX_VISIBLE: usize = 8;
23
24pub fn longest_common_prefix<'a>(s1: &'a str, s2: &'a str) -> &'a str {
27 let limit = s1.len().min(s2.len());
28 let mut idx = 0;
29 while idx < limit && s1.as_bytes()[idx] == s2.as_bytes()[idx] {
30 idx += 1;
31 }
32 &s1[..idx]
33}
34
35pub fn tab_completion_target(buffer: &str, matches: &[&CommandSpec]) -> Option<String> {
43 let buf = buffer.trim_start_matches('/');
44 match matches.len() {
45 0 => None,
46 1 => Some(matches[0].name.to_string()),
47 _ => {
48 let mut common: &str = matches[0].name;
49 for m in &matches[1..] {
50 common = longest_common_prefix(common, m.name);
51 if common.is_empty() {
52 break;
53 }
54 }
55 if common.len() > buf.len() {
56 Some(common.to_string())
57 } else {
58 None
59 }
60 }
61 }
62}
63
64pub fn popup_rect(input_area: Rect, candidate_count: usize, frame_area: Rect) -> Option<Rect> {
69 if candidate_count == 0 {
70 return None;
71 }
72 let popup_h = (candidate_count.min(MAX_VISIBLE) as u16) + 2;
74 if input_area.y < popup_h || frame_area.y > input_area.y - popup_h {
75 return None;
76 }
77 let popup_w = input_area.width.clamp(20, 60);
78 Some(Rect {
79 x: input_area.x,
80 y: input_area.y - popup_h,
81 width: popup_w,
82 height: popup_h,
83 })
84}
85
86enum MenuEntry<'a> {
89 Builtin(&'a crate::tui::commands::CommandSpec),
90 Skill(&'a crate::tui::skill_commands::SkillCommand),
91}
92
93impl<'a> MenuEntry<'a> {
94 fn name(&self) -> &str {
95 match self {
96 MenuEntry::Builtin(s) => s.name,
97 MenuEntry::Skill(s) => &s.name,
98 }
99 }
100 fn summary(&self) -> &str {
101 match self {
102 MenuEntry::Builtin(s) => s.summary,
103 MenuEntry::Skill(s) => &s.description,
104 }
105 }
106 fn is_skill(&self) -> bool {
107 matches!(self, MenuEntry::Skill(_))
108 }
109}
110
111pub fn render(frame: &mut Frame, input_area: Rect, app: &App) {
118 if app.prompt.mode != crate::tui::app::InputMode::Command {
119 return;
120 }
121 let builtin_matches = app.commands.search(&app.prompt.buffer);
122 let skill_matches = app.commands.search_skills(&app.prompt.buffer);
123
124 let mut combined: Vec<MenuEntry<'_>> = builtin_matches
126 .iter()
127 .map(|s| MenuEntry::Builtin(s))
128 .chain(skill_matches.iter().map(|s| MenuEntry::Skill(s)))
129 .collect();
130 combined.truncate(MAX_VISIBLE);
131
132 if combined.is_empty() {
133 return;
134 }
135 let Some(area) = popup_rect(input_area, combined.len(), frame.area()) else {
136 return;
137 };
138 frame.render_widget(Clear, area);
139
140 let selected_style = Style::default()
141 .fg(Color::Black)
142 .bg(Color::Yellow)
143 .add_modifier(Modifier::BOLD);
144 let normal_style = Style::default().fg(Color::White);
145 let summary_style = Style::default().fg(Color::DarkGray);
146 let skill_badge_style = Style::default().fg(Color::Green);
147
148 let lines: Vec<Line<'static>> = combined
149 .iter()
150 .enumerate()
151 .map(|(i, entry)| {
152 let style = if app.command_menu_selected == Some(i) {
153 selected_style
154 } else {
155 normal_style
156 };
157 let mut spans = vec![
158 Span::styled(format!(" /{:<10} ", entry.name().to_string()), style),
159 Span::styled(entry.summary().to_string(), summary_style),
160 ];
161 if entry.is_skill() {
162 spans.push(Span::styled(" [skill]".to_string(), skill_badge_style));
163 }
164 Line::from(spans)
165 })
166 .collect();
167
168 let block = Block::default()
169 .borders(Borders::ALL)
170 .border_style(Style::default().fg(Color::DarkGray))
171 .title(" Commands ")
172 .style(Style::default().bg(Color::Black));
173 let para = Paragraph::new(lines).block(block);
174 frame.render_widget(para, area);
175}
176
177pub fn render_atfile(frame: &mut Frame, input_area: Rect, app: &App) {
180 if app.prompt.mode != crate::tui::app::InputMode::AtFile {
181 return;
182 }
183 let suggestions = &app.atfile_suggestions;
184 if suggestions.is_empty() {
185 return;
186 }
187 let visible = &suggestions[..suggestions.len().min(MAX_VISIBLE)];
188 let Some(area) = popup_rect(input_area, visible.len(), frame.area()) else {
189 return;
190 };
191 frame.render_widget(Clear, area);
192
193 let selected_style = Style::default()
194 .fg(Color::Black)
195 .bg(Color::Cyan)
196 .add_modifier(Modifier::BOLD);
197 let normal_style = Style::default().fg(Color::White);
198
199 let lines: Vec<Line<'static>> = visible
200 .iter()
201 .enumerate()
202 .map(|(i, path)| {
203 let style = if app.atfile_selected == Some(i) {
204 selected_style
205 } else {
206 normal_style
207 };
208 Line::from(Span::styled(format!(" {} ", path), style))
209 })
210 .collect();
211
212 let title = format!(" @files query: {:?} ", app.atfile_query);
213 let block = Block::default()
214 .borders(Borders::ALL)
215 .border_style(Style::default().fg(Color::Cyan))
216 .title(title)
217 .style(Style::default().bg(Color::Black));
218 let para = Paragraph::new(lines).block(block);
219 frame.render_widget(para, area);
220}
221
222pub fn render_history_search(frame: &mut Frame, input_area: Rect, app: &App) {
225 if app.prompt.mode != crate::tui::app::InputMode::HistorySearch {
226 return;
227 }
228 let match_count = app.hsearch_matches.len();
231 let visible_count = match_count.clamp(1, MAX_VISIBLE);
232 let Some(area) = popup_rect(input_area, visible_count, frame.area()) else {
233 return;
234 };
235 frame.render_widget(Clear, area);
236
237 let selected_style = Style::default()
238 .fg(Color::Black)
239 .bg(Color::LightGreen)
240 .add_modifier(Modifier::BOLD);
241 let normal_style = Style::default().fg(Color::White);
242 let empty_style = Style::default()
243 .fg(Color::DarkGray)
244 .add_modifier(Modifier::ITALIC);
245
246 let lines: Vec<Line<'static>> = if app.hsearch_matches.is_empty() {
247 vec![Line::from(Span::styled(" (no matches) ", empty_style))]
248 } else {
249 let history = &app.prompt.history;
250 app.hsearch_matches
251 .iter()
252 .take(MAX_VISIBLE)
253 .enumerate()
254 .map(|(i, &hist_idx)| {
255 let entry = history.get(hist_idx).map(String::as_str).unwrap_or("");
256 let display = if entry.len() > 60 {
258 format!(" {}… ", &entry[..57])
259 } else {
260 format!(" {} ", entry)
261 };
262 let style = if i == app.hsearch_selected {
263 selected_style
264 } else {
265 normal_style
266 };
267 Line::from(Span::styled(display, style))
268 })
269 .collect()
270 };
271
272 let title = format!(" 🔍 {} ", app.hsearch_query);
273 let block = Block::default()
274 .borders(Borders::ALL)
275 .border_style(Style::default().fg(Color::LightGreen))
276 .title(title)
277 .style(Style::default().bg(Color::Black));
278 let para = Paragraph::new(lines).block(block);
279 frame.render_widget(para, area);
280}
281
282pub fn render_permission_modal(frame: &mut Frame, app: &App) {
288 let Some(ref perm) = app.pending_permission else {
289 return;
290 };
291
292 let area = frame.area();
294 let modal_w = area.width.saturating_sub(8).min(72);
295 let modal_h = 8u16;
296 let x = (area.width.saturating_sub(modal_w)) / 2;
297 let y = (area.height.saturating_sub(modal_h)) / 2;
298 let modal_area = Rect::new(x, y, modal_w, modal_h);
299
300 frame.render_widget(Clear, modal_area);
301
302 let header_style = Style::default()
303 .fg(Color::Black)
304 .bg(Color::Yellow)
305 .add_modifier(Modifier::BOLD);
306 let body_style = Style::default().fg(Color::White);
307 let hint_style = Style::default()
308 .fg(Color::LightGreen)
309 .add_modifier(Modifier::BOLD);
310 let muted_style = Style::default().fg(Color::DarkGray);
311
312 let tool_line = Line::from(vec![
313 Span::styled(" Tool: ", header_style),
314 Span::styled(perm.tool_name.clone(), body_style),
315 ]);
316
317 let args_preview = if perm.args_preview.is_empty() {
318 "(no arguments)".to_string()
319 } else {
320 perm.args_preview.clone()
321 };
322 let args_short: String = args_preview.chars().take(60).collect();
323 let args_truncated = if args_preview.chars().count() > 60 {
324 format!("{args_short}…")
325 } else {
326 args_short
327 };
328 let args_line = Line::from(vec![
329 Span::styled(" Args: ", muted_style),
330 Span::styled(args_truncated, body_style),
331 ]);
332
333 let sep_line = Line::from(Span::styled("─".repeat(modal_w as usize - 2), muted_style));
334
335 let hint_line = Line::from(vec![
336 Span::styled(" [Y]", hint_style),
337 Span::styled("/", muted_style),
338 Span::styled("[Enter]", hint_style),
339 Span::styled(" Allow ", body_style),
340 Span::styled(
341 "[N]",
342 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
343 ),
344 Span::styled("/", muted_style),
345 Span::styled("[Esc]", muted_style),
346 Span::styled(" Deny ", body_style),
347 Span::styled(
348 "[A]",
349 Style::default()
350 .fg(Color::LightCyan)
351 .add_modifier(Modifier::BOLD),
352 ),
353 Span::styled(" Allow All", body_style),
354 ]);
355
356 let lines = vec![
357 Line::from(""),
358 tool_line,
359 args_line,
360 Line::from(""),
361 sep_line,
362 hint_line,
363 ];
364
365 let block = Block::default()
366 .borders(Borders::ALL)
367 .border_style(Style::default().fg(Color::Yellow))
368 .title(Span::styled(
369 " ⚠ Permission Request ",
370 Style::default()
371 .fg(Color::Yellow)
372 .add_modifier(Modifier::BOLD),
373 ))
374 .style(Style::default().bg(Color::Black));
375
376 let para = Paragraph::new(lines).block(block);
377 frame.render_widget(para, modal_area);
378}
379
380#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::tui::app::{App, AppScreen, InputMode};
388 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
389
390 fn key(code: KeyCode) -> KeyEvent {
391 KeyEvent::new(code, KeyModifiers::NONE)
392 }
393
394 fn fresh_command_app() -> App {
395 let mut app = App::new();
396 app.screen = AppScreen::Chat;
397 app.prompt.mode = InputMode::Command;
398 app
399 }
400
401 #[test]
402 fn longest_common_prefix_works() {
403 assert_eq!(longest_common_prefix("clear", "compact"), "c");
404 assert_eq!(longest_common_prefix("help", "help"), "help");
405 assert_eq!(longest_common_prefix("a", "b"), "");
406 }
407
408 #[test]
409 fn tab_completes_unique_prefix() {
410 let r = crate::tui::commands::CommandRegistry::default_set();
411 let matches = r.search("he");
413 let target = tab_completion_target("he", &matches);
414 assert_eq!(target.as_deref(), Some("help"));
415 }
416
417 #[test]
418 fn tab_extends_to_common_prefix_with_multiple_matches() {
419 let r = crate::tui::commands::CommandRegistry::default_set();
420 let matches = r.search("co");
423 assert_eq!(tab_completion_target("co", &matches), None);
424 let matches = r.search("c");
427 assert_eq!(tab_completion_target("c", &matches), None);
428 }
429
430 #[test]
431 fn tab_with_no_matches_returns_none() {
432 let r = crate::tui::commands::CommandRegistry::default_set();
433 let matches = r.search("zz");
434 assert!(tab_completion_target("zz", &matches).is_none());
435 }
436
437 #[test]
438 fn up_down_moves_selection() {
439 let mut app = fresh_command_app();
440 app.prompt.buffer = "c".into();
442 app.prompt.cursor = 1;
443
444 let _ = app.handle_key(key(KeyCode::Down));
446 assert_eq!(app.command_menu_selected, Some(0));
447 let _ = app.handle_key(key(KeyCode::Down));
448 assert_eq!(app.command_menu_selected, Some(1));
449 let _ = app.handle_key(key(KeyCode::Up));
450 assert_eq!(app.command_menu_selected, Some(0));
451 let _ = app.handle_key(key(KeyCode::Up));
453 assert_eq!(app.command_menu_selected, None);
454 }
455
456 #[test]
457 fn enter_runs_selected_command() {
458 let mut app = fresh_command_app();
459 app.prompt.buffer = "c".into();
460 app.prompt.cursor = 1;
461 let _ = app.handle_key(key(KeyCode::Down));
463 assert_eq!(app.command_menu_selected, Some(0));
464 let _ = app.handle_key(key(KeyCode::Enter));
465 assert_eq!(app.blocks.len(), 1);
467 match &app.blocks[0] {
468 crate::tui::app::TranscriptBlock::System { text } => {
469 assert!(text.contains("cleared"), "got {text:?}")
470 }
471 other => panic!("expected System, got {other:?}"),
472 }
473 }
474
475 #[test]
476 fn tab_completes_buffer() {
477 let mut app = fresh_command_app();
478 app.prompt.buffer = "he".into();
479 app.prompt.cursor = 2;
480 let _ = app.handle_key(key(KeyCode::Tab));
481 assert_eq!(app.prompt.buffer, "help");
483 }
484
485 #[test]
486 fn esc_clears_buffer_and_exits_command_mode() {
487 let mut app = fresh_command_app();
488 app.prompt.buffer = "co".into();
489 app.prompt.cursor = 2;
490 let _ = app.handle_key(key(KeyCode::Esc));
493 assert!(app.prompt.buffer.is_empty());
494 assert_eq!(app.prompt.mode, InputMode::Prompt);
495 }
496}