diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 0de4d5d..92c5e5b 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -51,6 +51,8 @@ pub struct ChatApp { pending_key: Option, // For multi-key sequences like gg, dd clipboard: String, // Vim-style clipboard for yank/paste command_buffer: String, // Buffer for command mode input + command_suggestions: Vec, // Filtered command suggestions based on current input + selected_suggestion: usize, // Index of selected suggestion visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels focused_panel: FocusedPanel, // Currently focused panel for scrolling @@ -100,6 +102,8 @@ impl ChatApp { pending_key: None, clipboard: String::new(), command_buffer: String::new(), + command_suggestions: Vec::new(), + selected_suggestion: 0, visual_start: None, visual_end: None, focused_panel: FocusedPanel::Input, @@ -206,6 +210,76 @@ impl ChatApp { &self.command_buffer } + pub fn command_suggestions(&self) -> &[String] { + &self.command_suggestions + } + + pub fn selected_suggestion(&self) -> usize { + self.selected_suggestion + } + + /// Returns all available commands with their aliases + fn get_all_commands() -> Vec<(&'static str, &'static str)> { + vec![ + ("quit", "Exit the application"), + ("q", "Alias for quit"), + ("clear", "Clear the conversation"), + ("c", "Alias for clear"), + ("w", "Alias for write"), + ("save", "Alias for write"), + ("load", "Load a saved conversation"), + ("open", "Alias for load"), + ("o", "Alias for load"), + ("sessions", "List saved sessions"), + ("ls", "Alias for sessions"), + ("help", "Show help documentation"), + ("h", "Alias for help"), + ("model", "Select a model"), + ("m", "Alias for model"), + ("new", "Start a new conversation"), + ("n", "Alias for new"), + ] + } + + /// Update command suggestions based on current input + fn update_command_suggestions(&mut self) { + let input = self.command_buffer.trim(); + + if input.is_empty() { + // Show all commands when input is empty + self.command_suggestions = Self::get_all_commands() + .iter() + .map(|(cmd, _)| cmd.to_string()) + .collect(); + } else { + // Filter commands that start with the input + self.command_suggestions = Self::get_all_commands() + .iter() + .filter_map(|(cmd, _)| { + if cmd.starts_with(input) { + Some(cmd.to_string()) + } else { + None + } + }) + .collect(); + } + + // Reset selection if out of bounds + if self.selected_suggestion >= self.command_suggestions.len() { + self.selected_suggestion = 0; + } + } + + /// Complete the current command with the selected suggestion + fn complete_command(&mut self) { + if let Some(suggestion) = self.command_suggestions.get(self.selected_suggestion) { + self.command_buffer = suggestion.clone(); + self.update_command_suggestions(); + self.status = format!(":{}", self.command_buffer); + } + } + pub fn focused_panel(&self) -> FocusedPanel { self.focused_panel } @@ -416,6 +490,8 @@ impl ChatApp { (KeyCode::Char(':'), KeyModifiers::NONE) => { self.mode = InputMode::Command; self.command_buffer.clear(); + self.selected_suggestion = 0; + self.update_command_suggestions(); self.status = ":".to_string(); } // Enter editing mode @@ -1000,8 +1076,28 @@ impl ChatApp { (KeyCode::Esc, _) => { self.mode = InputMode::Normal; self.command_buffer.clear(); + self.command_suggestions.clear(); self.reset_status(); } + (KeyCode::Tab, _) => { + // Tab completion + self.complete_command(); + } + (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => { + // Navigate up in suggestions + if !self.command_suggestions.is_empty() { + self.selected_suggestion = self + .selected_suggestion + .saturating_sub(1); + } + } + (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => { + // Navigate down in suggestions + if !self.command_suggestions.is_empty() { + self.selected_suggestion = (self.selected_suggestion + 1) + .min(self.command_suggestions.len().saturating_sub(1)); + } + } (KeyCode::Enter, _) => { // Execute command let cmd = self.command_buffer.trim(); @@ -1055,6 +1151,7 @@ impl ChatApp { self.selected_session_index = 0; self.mode = InputMode::SessionBrowser; self.command_buffer.clear(); + self.command_suggestions.clear(); return Ok(AppState::Running); } Err(e) => { @@ -1070,6 +1167,7 @@ impl ChatApp { self.selected_session_index = 0; self.mode = InputMode::SessionBrowser; self.command_buffer.clear(); + self.command_suggestions.clear(); return Ok(AppState::Running); } Err(e) => { @@ -1080,12 +1178,14 @@ impl ChatApp { "h" | "help" => { self.mode = InputMode::Help; self.command_buffer.clear(); + self.command_suggestions.clear(); return Ok(AppState::Running); } "m" | "model" => { self.refresh_models().await?; self.mode = InputMode::ProviderSelection; self.command_buffer.clear(); + self.command_suggestions.clear(); return Ok(AppState::Running); } "n" | "new" => { @@ -1097,15 +1197,18 @@ impl ChatApp { } } self.command_buffer.clear(); + self.command_suggestions.clear(); self.mode = InputMode::Normal; } (KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => { self.command_buffer.push(c); + self.update_command_suggestions(); self.status = format!(":{}", self.command_buffer); } (KeyCode::Backspace, _) => { self.command_buffer.pop(); + self.update_command_suggestions(); self.status = format!(":{}", self.command_buffer); } _ => {} diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 517f97f..ee40988 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -82,6 +82,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { InputMode::ModelSelection => render_model_selector(frame, app), InputMode::Help => render_help(frame, app), InputMode::SessionBrowser => render_session_browser(frame, app), + InputMode::Command => render_command_suggestions(frame, app), _ => {} } } @@ -1464,6 +1465,63 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { frame.render_widget(footer, layout[1]); } +fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { + let suggestions = app.command_suggestions(); + + // Only show suggestions if there are any + if suggestions.is_empty() { + return; + } + + // Create a small popup near the status bar (bottom of screen) + let frame_height = frame.area().height; + let suggestion_count = suggestions.len().min(8); // Show max 8 suggestions + let popup_height = (suggestion_count as u16) + 2; // +2 for borders + + // Position the popup above the status bar + let popup_area = Rect { + x: 1, + y: frame_height.saturating_sub(popup_height + 3), // 3 for status bar height + width: 40.min(frame.area().width - 2), + height: popup_height, + }; + + frame.render_widget(Clear, popup_area); + + let items: Vec = suggestions + .iter() + .enumerate() + .map(|(idx, cmd)| { + let is_selected = idx == app.selected_suggestion(); + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + ListItem::new(Span::styled(cmd.to_string(), style)) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title(Span::styled( + " Commands (Tab to complete) ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ); + + frame.render_widget(list, popup_area); +} + fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let vertical = Layout::default() .direction(Direction::Vertical)