diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 15f1fb63..0326a19a 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -10,12 +10,21 @@ use crate::block::{ Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState, }; use crate::cache::FileCache; -use crate::command::{CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry}; +use crate::command::{ + CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry, +}; use crate::input::InputBuffer; use crate::scroll::Scroll; use crate::task::TaskStore; use crate::ui::Mode; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandCompletionApply { + Applied, + Ambiguous, + NoCandidates, +} + /// In-flight completion popup state. Lives on `App` while the user is /// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger /// is invalidated (cursor moved out, whitespace landed inside the @@ -99,6 +108,7 @@ pub struct App { pub command_input: InputBuffer, pub input_mode: CommandInputMode, pub command_registry: CommandRegistry, + command_completion_selected: Option, pub quit: bool, /// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// records the instant; a second press within the timeout exits the @@ -158,6 +168,7 @@ impl App { command_input: InputBuffer::new(), input_mode: CommandInputMode::Composer, command_registry: CommandRegistry::default(), + command_completion_selected: None, quit: false, quit_confirm: None, blocks: Vec::new(), @@ -1078,26 +1089,137 @@ impl App { pub fn enter_command_mode(&mut self) { self.input_mode = CommandInputMode::Command; self.completion = None; + self.command_completion_selected = None; self.quit_confirm = None; } pub fn exit_command_mode(&mut self) { self.input_mode = CommandInputMode::Composer; self.command_input.clear(); + self.command_completion_selected = None; } pub fn clear_command_input(&mut self) { self.command_input.clear(); + self.command_completion_selected = None; } pub fn command_text(&self) -> String { self.command_input.plain_text() } - pub fn command_suggestions(&self) -> Vec { + pub fn command_suggestions(&self) -> Vec { self.command_registry.suggest(&self.command_text()) } + pub fn command_completion_selected(&self) -> Option { + let selected = self.command_completion_selected?; + (selected < self.command_suggestions().len()).then_some(selected) + } + + pub fn command_completion_active(&self) -> bool { + !self.command_suggestions().is_empty() + } + + pub fn move_command_completion_up(&mut self) { + let len = self.command_suggestions().len(); + if len == 0 { + self.command_completion_selected = None; + return; + } + self.command_completion_selected = Some(match self.command_completion_selected() { + Some(0) | None => len - 1, + Some(selected) => selected - 1, + }); + } + + pub fn move_command_completion_down(&mut self) { + let len = self.command_suggestions().len(); + if len == 0 { + self.command_completion_selected = None; + return; + } + self.command_completion_selected = Some(match self.command_completion_selected() { + Some(selected) => (selected + 1) % len, + None => 0, + }); + } + + pub fn apply_command_completion(&mut self) -> CommandCompletionApply { + let suggestions = self.command_suggestions(); + let candidate = match self.command_completion_selected() { + Some(selected) => suggestions.get(selected), + None if suggestions.len() == 1 => suggestions.first(), + None if suggestions.is_empty() => return CommandCompletionApply::NoCandidates, + None => return self.ambiguous_command_completion(), + }; + + let Some(candidate) = candidate else { + self.command_completion_selected = None; + return CommandCompletionApply::NoCandidates; + }; + self.replace_command_name(candidate.name); + self.command_completion_selected = None; + CommandCompletionApply::Applied + } + + pub fn submit_command_with_completion(&mut self) -> Option { + let selected = self.command_completion_selected().is_some(); + let command_text = self.command_text(); + if command_text.trim().is_empty() && !selected { + return self.submit_command(); + } + if !selected && self.command_name_is_complete(&command_text) { + return self.submit_command(); + } + + match self.apply_command_completion() { + CommandCompletionApply::Applied | CommandCompletionApply::NoCandidates => { + self.submit_command() + } + CommandCompletionApply::Ambiguous => None, + } + } + + fn ambiguous_command_completion(&mut self) -> CommandCompletionApply { + self.push_command_diagnostic( + "Ambiguous command completion; select a candidate with Up/Down or keep typing.", + ); + CommandCompletionApply::Ambiguous + } + + fn command_name_is_complete(&self, command_line: &str) -> bool { + let trimmed = command_line.trim_start(); + let name = trimmed + .find(char::is_whitespace) + .map(|idx| &trimmed[..idx]) + .unwrap_or(trimmed); + !name.is_empty() && self.command_registry.find(name).is_some() + } + + fn replace_command_name(&mut self, canonical_name: &str) { + let command_line = self.command_text(); + let leading_len = command_line.len() - command_line.trim_start().len(); + let after_leading = &command_line[leading_len..]; + let name_end = after_leading + .find(char::is_whitespace) + .map(|idx| leading_len + idx) + .unwrap_or(command_line.len()); + let rest = &command_line[name_end..]; + + let mut completed = String::with_capacity(command_line.len().max(canonical_name.len() + 1)); + completed.push_str(&command_line[..leading_len]); + completed.push_str(canonical_name); + if rest.is_empty() { + completed.push(' '); + } else { + completed.push_str(rest); + } + + self.command_input.clear(); + self.command_input.insert_str(&completed); + } + fn command_environment(&self) -> CommandEnvironment { CommandEnvironment { connected: self.connected, @@ -1119,9 +1241,11 @@ impl App { } if result.clear_input { self.command_input.clear(); + self.command_completion_selected = None; } if result.exit_command_mode { self.input_mode = CommandInputMode::Composer; + self.command_completion_selected = None; } result.method } @@ -1146,23 +1270,40 @@ impl App { // stay readable. In command mode these operate on the command line, // keeping the normal composer buffer intact. pub fn insert_char(&mut self, c: char) { + let command_mode = self.is_command_mode(); self.active_input_mut().insert_char(c); + if command_mode { + self.command_completion_selected = None; + } } pub fn insert_newline(&mut self) { + let command_mode = self.is_command_mode(); self.active_input_mut().insert_newline(); + if command_mode { + self.command_completion_selected = None; + } } pub fn insert_paste(&mut self, content: String) { if self.is_command_mode() { self.command_input.insert_str(&content); + self.command_completion_selected = None; } else { self.input.insert_paste(content); } } pub fn delete_char_before(&mut self) { + let command_mode = self.is_command_mode(); self.active_input_mut().delete_before(); + if command_mode { + self.command_completion_selected = None; + } } pub fn delete_char_after(&mut self) { + let command_mode = self.is_command_mode(); self.active_input_mut().delete_after(); + if command_mode { + self.command_completion_selected = None; + } } pub fn move_cursor_left(&mut self) { self.active_input_mut().move_left(); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 6d4d4f8a..15430301 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -992,7 +992,7 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option { app.exit_command_mode(); None } - KeyCode::Enter => app.submit_command(), + KeyCode::Enter => app.submit_command_with_completion(), KeyCode::Backspace => { if app.command_text().is_empty() { app.exit_command_mode(); @@ -1014,11 +1014,19 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option { None } KeyCode::Up => { - app.move_cursor_up(); + if app.command_completion_active() { + app.move_command_completion_up(); + } else { + app.move_cursor_up(); + } None } KeyCode::Down => { - app.move_cursor_down(); + if app.command_completion_active() { + app.move_command_completion_down(); + } else { + app.move_cursor_down(); + } None } KeyCode::Home => { @@ -1029,7 +1037,10 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option { app.move_cursor_end(); None } - KeyCode::Tab => None, + KeyCode::Tab => { + app.apply_command_completion(); + None + } KeyCode::Char(c) => { if key .modifiers @@ -1558,6 +1569,204 @@ mod tests { assert_eq!(suggestions[0].name, "noop"); } + #[test] + fn command_completion_tab_applies_unambiguous_candidate() { + let mut app = App::new("agent".to_string()); + enter_command_mode(&mut app); + type_keys(&mut app, "no"); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), "noop "); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn command_completion_enter_applies_and_executes_unambiguous_candidate() { + let mut app = App::new("agent".to_string()); + enter_command_mode(&mut app); + type_keys(&mut app, "no"); + + let method = handle_key(&mut app, key(KeyCode::Enter)); + + assert!(method.is_none()); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + assert!(has_alert(&app, "noop: no action")); + } + + #[test] + fn command_completion_ambiguous_candidate_requires_selection_or_more_input() { + let mut app = App::new("agent".to_string()); + register_test_command(&mut app, "open", "open", parse_no_args, "open executed"); + register_test_command( + &mut app, + "options", + "options", + parse_no_args, + "options executed", + ); + enter_command_mode(&mut app); + type_keys(&mut app, "o"); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + assert_eq!(app.command_text(), "o"); + assert!(app.is_command_mode()); + assert!(has_alert(&app, "Ambiguous command completion")); + + let before = app.blocks.len(); + let method = handle_key(&mut app, key(KeyCode::Enter)); + assert!(method.is_none()); + assert_eq!(app.command_text(), "o"); + assert!(app.is_command_mode()); + assert!(app.blocks.len() > before); + assert!(!has_alert(&app, "open executed")); + assert!(!has_alert(&app, "options executed")); + } + + #[test] + fn command_completion_selected_candidate_applies_on_enter() { + let mut app = App::new("agent".to_string()); + register_test_command(&mut app, "open", "open", parse_no_args, "open executed"); + register_test_command( + &mut app, + "options", + "options", + parse_no_args, + "options executed", + ); + enter_command_mode(&mut app); + type_keys(&mut app, "o"); + + assert!(handle_key(&mut app, key(KeyCode::Down)).is_none()); + let method = handle_key(&mut app, key(KeyCode::Enter)); + + assert!(method.is_none()); + assert!(!app.is_command_mode()); + assert!(has_alert(&app, "open executed")); + assert!(!has_alert(&app, "options executed")); + } + + #[test] + fn command_completion_argument_required_keeps_command_mode_after_name_completion() { + let mut app = App::new("agent".to_string()); + register_test_command( + &mut app, + "open", + "open ", + parse_required_arg, + "open executed", + ); + enter_command_mode(&mut app); + type_keys(&mut app, "op"); + + let method = handle_key(&mut app, key(KeyCode::Enter)); + + assert!(method.is_none()); + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), "open "); + assert!(has_alert(&app, "Invalid arguments. Usage: open ")); + assert!(!has_alert(&app, "open executed")); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn command_completion_does_not_affect_normal_composer_without_popup() { + let mut app = App::new("agent".to_string()); + type_keys(&mut app, "hello"); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), "hello"); + } + + fn enter_command_mode(app: &mut App) { + assert!(handle_key(app, key(KeyCode::Char(':'))).is_none()); + assert!(app.is_command_mode()); + } + + fn type_keys(app: &mut App, text: &str) { + for c in text.chars() { + assert!(handle_key(app, key(KeyCode::Char(c))).is_none()); + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn has_alert(app: &App, needle: &str) -> bool { + app.blocks.iter().any(|block| match block { + crate::block::Block::Alert { message, .. } => message.contains(needle), + _ => false, + }) + } + + fn register_test_command( + app: &mut App, + name: &'static str, + usage: &'static str, + argument_parser: crate::command::ArgumentParser, + message: &'static str, + ) { + app.command_registry.register(crate::command::CommandSpec { + name, + aliases: &[], + usage, + description: "test command", + argument_parser, + can_execute: test_command_available, + executor: test_command_executor, + }); + TEST_COMMAND_MESSAGES.with(|messages| messages.borrow_mut().push((name, message))); + } + + thread_local! { + static TEST_COMMAND_MESSAGES: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; + } + + fn parse_no_args( + raw: &str, + ) -> Result { + Ok(crate::command::CommandArgs::parse_whitespace(raw)) + } + + fn parse_required_arg( + raw: &str, + ) -> Result { + let args = crate::command::CommandArgs::parse_whitespace(raw); + if args.argv().is_empty() { + return Err(crate::command::CommandDiagnostic::new( + "Invalid arguments. Usage: open ", + )); + } + Ok(args) + } + + fn test_command_available( + _environment: &crate::command::CommandEnvironment, + ) -> Result<(), crate::command::CommandDiagnostic> { + Ok(()) + } + + fn test_command_executor( + invocation: crate::command::CommandInvocation<'_>, + ) -> crate::command::CommandExecution { + let message = TEST_COMMAND_MESSAGES + .with(|messages| { + messages + .borrow() + .iter() + .find(|(name, _)| *name == invocation.command.name) + .map(|(_, message)| *message) + }) + .unwrap_or("test command executed"); + crate::command::CommandExecution::notice(message) + } + fn input_text(app: &App) -> String { protocol::Segment::flatten_to_text(&app.input.submit_segments()) } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 5ef06cb9..5cf5ddd5 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -291,11 +291,29 @@ fn draw_command_popup(frame: &mut Frame, app: &App, input_area: Rect) { .add_modifier(Modifier::BOLD); let description_style = Style::default().fg(Color::DarkGray); let mut lines: Vec> = Vec::with_capacity(popup_h as usize); - for candidate in visible_suggestions.iter().take(popup_h as usize) { + let selected = app.command_completion_selected(); + for (idx, candidate) in visible_suggestions + .iter() + .take(popup_h as usize) + .enumerate() + { + let selected_style = if Some(idx) == selected { + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; lines.push(Line::from(vec![ - Span::styled(candidate.name.to_owned(), command_style), - Span::styled(" — ", description_style), - Span::styled(candidate.description.to_owned(), description_style), + Span::styled( + candidate.name.to_owned(), + command_style.patch(selected_style), + ), + Span::styled(" — ", description_style.patch(selected_style)), + Span::styled( + candidate.description.to_owned(), + description_style.patch(selected_style), + ), ])); }