diff --git a/tycode-cli/Cargo.toml b/tycode-cli/Cargo.toml index 7c3714e..0031f8d 100644 --- a/tycode-cli/Cargo.toml +++ b/tycode-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycode-cli" -version = "0.4.0" +version = "0.4.1" edition = "2021" authors = ["tigy"] description = "CLI interface for TyCode" @@ -26,7 +26,8 @@ terminal_size = "0.4" # Utilities anyhow = { workspace = true } dirs = "5.0" -rustyline = "15" +rustyline = { version = "15", features = ["custom-bindings"] } +crossterm = "0.28" # Logging and tracing tracing = { workspace = true } diff --git a/tycode-cli/src/autocomplete/completer.rs b/tycode-cli/src/autocomplete/completer.rs new file mode 100644 index 0000000..ff1558c --- /dev/null +++ b/tycode-cli/src/autocomplete/completer.rs @@ -0,0 +1,43 @@ +use tycode_core::chat::commands::{get_available_commands, CommandInfo}; + +use super::CommandSuggestion; + +pub struct CommandCompleter { + commands: Vec, +} + +impl Default for CommandCompleter { + fn default() -> Self { + Self::new() + } +} + +impl CommandCompleter { + pub fn new() -> Self { + // Filter out hidden commands + let commands: Vec = get_available_commands() + .into_iter() + .filter(|cmd| !cmd.hidden) + .collect(); + + Self { commands } + } + + /// Filter commands based on partial input (characters after "/") + /// Returns all non-hidden commands when filter is empty + pub fn filter(&self, filter: &str) -> Vec { + let filter_lower = filter.to_lowercase(); + + self.commands + .iter() + .filter(|cmd| { + // Filter on command name only (not description) + filter.is_empty() || cmd.name.to_lowercase().starts_with(&filter_lower) + }) + .map(|cmd| CommandSuggestion { + name: cmd.name.clone(), + description: cmd.description.clone(), + }) + .collect() + } +} diff --git a/tycode-cli/src/autocomplete/helper.rs b/tycode-cli/src/autocomplete/helper.rs new file mode 100644 index 0000000..75c0529 --- /dev/null +++ b/tycode-cli/src/autocomplete/helper.rs @@ -0,0 +1,359 @@ +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::{CmdKind, Highlighter}; +use rustyline::hint::Hinter; +use rustyline::validate::{ValidationContext, ValidationResult, Validator}; +use rustyline::{Cmd, ConditionalEventHandler, Context, Event, EventContext, RepeatCount}; +use rustyline::Helper; +use std::borrow::Cow; +use std::sync::{Arc, Mutex}; + +use super::{AutocompleteState, CommandCompleter, SuggestionRenderer}; + +/// Shared state between the helper and event handlers +pub struct SharedAutocompleteState { + pub state: Mutex, + pub completer: CommandCompleter, + pub renderer: Mutex, + pub terminal_width: usize, +} + +impl SharedAutocompleteState { + pub fn new(terminal_width: usize) -> Self { + Self { + state: Mutex::new(AutocompleteState::new()), + completer: CommandCompleter::new(), + renderer: Mutex::new(SuggestionRenderer::new()), + terminal_width, + } + } + + /// Update suggestions based on current input line + pub fn update_suggestions(&self, line: &str) { + let mut state = self.state.lock().unwrap(); + + // If we just selected a command, suppress reactivation entirely + // The flag is only cleared by backspace handler or when line is submitted + if state.just_selected_command.is_some() { + // Don't reactivate suggestions after a selection + // User must press backspace or submit the line to reset + return; + } + + if let Some(filter) = line.strip_prefix('/') { + state.filter_text = filter.to_string(); + state.suggestions = self.completer.filter(filter); + state.active = !state.suggestions.is_empty(); + state.selected_index = 0; // Reset selection when filter changes + + // Render suggestions + if state.active { + if let Ok(mut renderer) = self.renderer.lock() { + let _ = renderer.render( + &state.suggestions, + state.selected_index, + self.terminal_width, + ); + } + } + } else { + self.deactivate_internal(&mut state); + } + } + + fn deactivate_internal(&self, state: &mut AutocompleteState) { + if state.active { + state.deactivate(); + if let Ok(mut renderer) = self.renderer.lock() { + let _ = renderer.clear(); + } + } + } + + /// Full deactivation including clearing the just_selected_command flag + pub fn deactivate_full(&self) { + let mut state = self.state.lock().unwrap(); + state.deactivate_full(); + if let Ok(mut renderer) = self.renderer.lock() { + let _ = renderer.clear(); + } + } +} + +/// Custom helper that integrates autocomplete +pub struct TycodeHelper { + pub shared: Arc, +} + +impl TycodeHelper { + pub fn new(terminal_width: usize) -> Self { + Self { + shared: Arc::new(SharedAutocompleteState::new(terminal_width)), + } + } +} + +// Implement required traits for Helper +impl Completer for TycodeHelper { + type Candidate = Pair; + + fn complete( + &self, + _line: &str, + _pos: usize, + _ctx: &Context<'_>, + ) -> Result<(usize, Vec), ReadlineError> { + // We handle completion manually via our overlay, so return empty + Ok((0, Vec::new())) + } +} + +impl Hinter for TycodeHelper { + type Hint = String; + + fn hint(&self, line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + // Trigger suggestion updates on hint calls (called after each character) + self.shared.update_suggestions(line); + None + } +} + +impl Highlighter for TycodeHelper { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + // Highlight commands in magenta + if line.starts_with('/') { + Cow::Owned(format!("\x1b[35m{}\x1b[0m", line)) + } else { + Cow::Borrowed(line) + } + } + + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + _default: bool, + ) -> Cow<'b, str> { + Cow::Borrowed(prompt) + } + + fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool { + true // Always refresh highlighting + } +} + +impl Validator for TycodeHelper { + fn validate(&self, _ctx: &mut ValidationContext) -> Result { + Ok(ValidationResult::Valid(None)) + } +} + +impl Helper for TycodeHelper {} + +/// Event handler for Up arrow - navigate suggestions +#[derive(Clone)] +pub struct AutocompleteUpHandler { + pub shared: Arc, +} + +impl ConditionalEventHandler for AutocompleteUpHandler { + fn handle( + &self, + _evt: &Event, + _n: RepeatCount, + _positive: bool, + _ctx: &EventContext, + ) -> Option { + let mut state = self.shared.state.lock().unwrap(); + + if state.active && !state.suggestions.is_empty() { + state.move_selection_up(); + + let suggestions = state.suggestions.clone(); + let selected = state.selected_index; + drop(state); + + if let Ok(mut renderer) = self.shared.renderer.lock() { + let _ = renderer.render(&suggestions, selected, self.shared.terminal_width); + } + + Some(Cmd::Noop) // Consume the key, don't do history navigation + } else { + None // Default behavior (history navigation) + } + } +} + +/// Event handler for Down arrow - navigate suggestions +#[derive(Clone)] +pub struct AutocompleteDownHandler { + pub shared: Arc, +} + +impl ConditionalEventHandler for AutocompleteDownHandler { + fn handle( + &self, + _evt: &Event, + _n: RepeatCount, + _positive: bool, + _ctx: &EventContext, + ) -> Option { + let mut state = self.shared.state.lock().unwrap(); + + if state.active && !state.suggestions.is_empty() { + state.move_selection_down(); + + let suggestions = state.suggestions.clone(); + let selected = state.selected_index; + drop(state); + + if let Ok(mut renderer) = self.shared.renderer.lock() { + let _ = renderer.render(&suggestions, selected, self.shared.terminal_width); + } + + Some(Cmd::Noop) // Consume the key + } else { + None // Default behavior + } + } +} + +/// Event handler for Tab - select suggestion and populate input +#[derive(Clone)] +pub struct AutocompleteSelectHandler { + pub shared: Arc, +} + +impl ConditionalEventHandler for AutocompleteSelectHandler { + fn handle( + &self, + evt: &Event, + _n: RepeatCount, + _positive: bool, + _ctx: &EventContext, + ) -> Option { + let mut state = self.shared.state.lock().unwrap(); + + if state.active && !state.suggestions.is_empty() { + if let Some(suggestion) = state.get_selected().cloned() { + // Replace current line with selected command + let command = format!("/{}", suggestion.name); + + // Mark this command as just selected to prevent immediate reactivation + state.just_selected_command = Some(command.clone()); + + // Clear the suggestions display + state.deactivate(); + drop(state); + + if let Ok(mut renderer) = self.shared.renderer.lock() { + let _ = renderer.clear(); + } + + // Use Cmd::Replace to replace the entire line with the selected command + // This just populates the input; user needs to press Enter to execute + return Some(Cmd::Replace(rustyline::Movement::WholeBuffer, Some(command))); + } + } + + // Check if this is Enter key - if so, explicitly accept line + // (Tab can use default behavior which is None) + if let Some(key_evt) = evt.get(0) { + if matches!(key_evt, rustyline::KeyEvent(rustyline::KeyCode::Enter, _)) { + return Some(Cmd::AcceptLine); + } + } + + // Default behavior for Tab + None + } +} + +/// Event handler for Escape - deactivate suggestions +#[derive(Clone)] +pub struct AutocompleteEscapeHandler { + pub shared: Arc, +} + +impl ConditionalEventHandler for AutocompleteEscapeHandler { + fn handle( + &self, + _evt: &Event, + _n: RepeatCount, + _positive: bool, + _ctx: &EventContext, + ) -> Option { + let mut state = self.shared.state.lock().unwrap(); + + if state.active || state.just_selected_command.is_some() { + state.deactivate_full(); + drop(state); + + if let Ok(mut renderer) = self.shared.renderer.lock() { + let _ = renderer.clear(); + } + + Some(Cmd::Noop) // Consume escape + } else { + None // Default behavior + } + } +} + +/// Event handler for Backspace - update suggestions after deletion +#[derive(Clone)] +pub struct AutocompleteBackspaceHandler { + pub shared: Arc, +} + +impl ConditionalEventHandler for AutocompleteBackspaceHandler { + fn handle( + &self, + _evt: &Event, + _n: RepeatCount, + _positive: bool, + ctx: &EventContext, + ) -> Option { + let line = ctx.line(); + let pos = ctx.pos(); + + // Simulate what line will look like after backspace + if pos > 0 && pos <= line.len() { + let new_line = format!("{}{}", &line[..pos - 1], &line[pos..]); + + let mut state = self.shared.state.lock().unwrap(); + + // Clear the just_selected flag - user is editing + state.just_selected_command = None; + + if let Some(filter) = new_line.strip_prefix('/') { + state.filter_text = filter.to_string(); + state.suggestions = self.shared.completer.filter(filter); + state.active = !state.suggestions.is_empty(); + state.selected_index = 0; + + if state.active { + let suggestions = state.suggestions.clone(); + let selected = state.selected_index; + drop(state); + + if let Ok(mut renderer) = self.shared.renderer.lock() { + let _ = renderer.render(&suggestions, selected, self.shared.terminal_width); + } + } else { + drop(state); + if let Ok(mut renderer) = self.shared.renderer.lock() { + let _ = renderer.clear(); + } + } + } else { + state.deactivate(); + drop(state); + if let Ok(mut renderer) = self.shared.renderer.lock() { + let _ = renderer.clear(); + } + } + } + + None // Use default backspace handling + } +} diff --git a/tycode-cli/src/autocomplete/mod.rs b/tycode-cli/src/autocomplete/mod.rs new file mode 100644 index 0000000..b2578ab --- /dev/null +++ b/tycode-cli/src/autocomplete/mod.rs @@ -0,0 +1,80 @@ +mod completer; +mod helper; +mod renderer; + +pub use completer::CommandCompleter; +pub use helper::{ + AutocompleteBackspaceHandler, AutocompleteDownHandler, AutocompleteEscapeHandler, + AutocompleteSelectHandler, AutocompleteUpHandler, TycodeHelper, +}; +pub use renderer::SuggestionRenderer; + +/// A command suggestion with display info +#[derive(Clone, Debug)] +pub struct CommandSuggestion { + pub name: String, + pub description: String, +} + +/// State for tracking autocomplete suggestions +pub struct AutocompleteState { + /// Whether autocomplete is currently active + pub active: bool, + /// Current filtered suggestions + pub suggestions: Vec, + /// Currently selected index (for arrow navigation) + pub selected_index: usize, + /// Current filter text (characters after "/") + pub filter_text: String, + /// Command that was just selected (to prevent immediate reactivation) + pub just_selected_command: Option, +} + +impl Default for AutocompleteState { + fn default() -> Self { + Self::new() + } +} + +impl AutocompleteState { + pub fn new() -> Self { + Self { + active: false, + suggestions: Vec::new(), + selected_index: 0, + filter_text: String::new(), + just_selected_command: None, + } + } + + pub fn move_selection_up(&mut self) { + if self.selected_index > 0 { + self.selected_index -= 1; + } else if !self.suggestions.is_empty() { + self.selected_index = self.suggestions.len() - 1; // Wrap around + } + } + + pub fn move_selection_down(&mut self) { + if !self.suggestions.is_empty() { + self.selected_index = (self.selected_index + 1) % self.suggestions.len(); + } + } + + pub fn get_selected(&self) -> Option<&CommandSuggestion> { + self.suggestions.get(self.selected_index) + } + + pub fn deactivate(&mut self) { + self.active = false; + self.suggestions.clear(); + self.selected_index = 0; + self.filter_text.clear(); + // Note: don't clear just_selected_command here - it's cleared separately + } + + pub fn deactivate_full(&mut self) { + self.deactivate(); + self.just_selected_command = None; + } +} diff --git a/tycode-cli/src/autocomplete/renderer.rs b/tycode-cli/src/autocomplete/renderer.rs new file mode 100644 index 0000000..8713754 --- /dev/null +++ b/tycode-cli/src/autocomplete/renderer.rs @@ -0,0 +1,150 @@ +use crossterm::{ + cursor::{MoveToColumn, MoveUp, RestorePosition, SavePosition}, + queue, + style::{Color, Print, ResetColor, SetForegroundColor}, + terminal::{Clear, ClearType}, +}; +use std::io::{stdout, Write}; + +use super::CommandSuggestion; + +/// Handles rendering suggestions below the input line +pub struct SuggestionRenderer { + /// Number of suggestion lines currently displayed (for cleanup) + rendered_line_count: usize, +} + +impl Default for SuggestionRenderer { + fn default() -> Self { + Self::new() + } +} + +impl SuggestionRenderer { + pub fn new() -> Self { + Self { + rendered_line_count: 0, + } + } + + /// Render suggestions below the current input line + pub fn render( + &mut self, + suggestions: &[CommandSuggestion], + selected_index: usize, + terminal_width: usize, + ) -> std::io::Result<()> { + let mut stdout = stdout(); + + // First, clear any previously rendered suggestions + self.clear_internal(&mut stdout)?; + + if suggestions.is_empty() { + stdout.flush()?; + return Ok(()); + } + + // Save cursor position (at end of input line) + queue!(stdout, SavePosition)?; + + // Move to next line for suggestions + queue!(stdout, Print("\n"))?; + + let mut lines_rendered = 0; + + for (idx, suggestion) in suggestions.iter().enumerate() { + let is_selected = idx == selected_index; + + // Format: " /command - description" (truncated to fit terminal) + let prefix = if is_selected { "> " } else { " " }; + let command_part = format!("/{}", suggestion.name); + let separator = " - "; + + // Calculate available space for description + let used_width = prefix.len() + command_part.len() + separator.len(); + let desc_width = terminal_width.saturating_sub(used_width + 3); // +3 for "..." + + let description = if suggestion.description.chars().count() > desc_width && desc_width > 0 { + let truncated: String = suggestion.description.chars().take(desc_width).collect(); + format!("{}...", truncated) + } else { + suggestion.description.clone() + }; + + // Render with appropriate styling + if is_selected { + // Selected item: Cyan for command, DarkGrey for description + queue!( + stdout, + MoveToColumn(0), + SetForegroundColor(Color::Cyan), + Print(prefix), + Print(&command_part), + SetForegroundColor(Color::DarkGrey), + Print(separator), + Print(&description), + ResetColor, + )?; + } else { + // Non-selected: all DarkGrey (dimmed) + queue!( + stdout, + MoveToColumn(0), + SetForegroundColor(Color::DarkGrey), + Print(prefix), + Print(&command_part), + Print(separator), + Print(&description), + ResetColor, + )?; + } + + lines_rendered += 1; + + // Move to next line if not the last suggestion + if idx < suggestions.len() - 1 { + queue!(stdout, Print("\n"))?; + } + } + + self.rendered_line_count = lines_rendered; + + // Restore cursor position back to input line + queue!(stdout, RestorePosition)?; + + stdout.flush()?; + Ok(()) + } + + /// Clear previously rendered suggestions (internal helper) + fn clear_internal(&mut self, stdout: &mut std::io::Stdout) -> std::io::Result<()> { + if self.rendered_line_count == 0 { + return Ok(()); + } + + queue!(stdout, SavePosition)?; + + // Move down and clear each line + for _ in 0..self.rendered_line_count { + queue!(stdout, Print("\n"), MoveToColumn(0), Clear(ClearType::CurrentLine))?; + } + + // Move back up to original position + for _ in 0..self.rendered_line_count { + queue!(stdout, MoveUp(1))?; + } + + queue!(stdout, RestorePosition)?; + + self.rendered_line_count = 0; + Ok(()) + } + + /// Clear suggestions and flush + pub fn clear(&mut self) -> std::io::Result<()> { + let mut stdout = stdout(); + self.clear_internal(&mut stdout)?; + stdout.flush()?; + Ok(()) + } +} diff --git a/tycode-cli/src/interactive_app.rs b/tycode-cli/src/interactive_app.rs index 7f9f1dc..c561011 100644 --- a/tycode-cli/src/interactive_app.rs +++ b/tycode-cli/src/interactive_app.rs @@ -1,6 +1,7 @@ use anyhow::Result; use rustyline::error::ReadlineError; -use rustyline::DefaultEditor; +use rustyline::history::DefaultHistory; +use rustyline::{Editor, EventHandler, KeyCode, KeyEvent, Modifiers}; use std::path::PathBuf; use std::thread; use terminal_size::{terminal_size, Width}; @@ -9,6 +10,10 @@ use tycode_core::chat::actor::{ChatActor, ChatActorBuilder}; use tycode_core::chat::events::{ChatEvent, MessageSender}; use tycode_core::formatter::{CompactFormatter, EventFormatter, VerboseFormatter}; +use crate::autocomplete::{ + AutocompleteBackspaceHandler, AutocompleteDownHandler, AutocompleteEscapeHandler, + AutocompleteSelectHandler, AutocompleteUpHandler, TycodeHelper, +}; use crate::commands::{handle_local_command, LocalCommandResult}; use crate::state::State; @@ -19,7 +24,10 @@ enum ReadlineResponse { Error(String), } -fn handle_readline(rl: &mut DefaultEditor, prompt: &str) -> ReadlineResponse { +fn handle_readline( + rl: &mut Editor, + prompt: &str, +) -> ReadlineResponse { match rl.readline(prompt) { Ok(line) => { if let Err(e) = rl.add_history_entry(&line) { @@ -33,7 +41,9 @@ fn handle_readline(rl: &mut DefaultEditor, prompt: &str) -> ReadlineResponse { } } -fn spawn_readline_thread() -> ( +fn spawn_readline_thread( + terminal_width: usize, +) -> ( mpsc::UnboundedSender, mpsc::UnboundedReceiver, ) { @@ -41,15 +51,73 @@ fn spawn_readline_thread() -> ( let (response_tx, response_rx) = mpsc::unbounded_channel::(); thread::spawn(move || { - let Ok(mut rl) = DefaultEditor::new() else { + let Ok(mut rl) = Editor::::new() else { let _ = response_tx.send(ReadlineResponse::Error( "Failed to create editor".to_string(), )); return; }; + // Set up the helper with autocomplete support + let helper = TycodeHelper::new(terminal_width); + let shared = helper.shared.clone(); + rl.set_helper(Some(helper)); + + // Bind event handlers for autocomplete + // Up arrow - navigate suggestions + rl.bind_sequence( + KeyEvent(KeyCode::Up, Modifiers::NONE), + EventHandler::Conditional(Box::new(AutocompleteUpHandler { + shared: shared.clone(), + })), + ); + + // Down arrow - navigate suggestions + rl.bind_sequence( + KeyEvent(KeyCode::Down, Modifiers::NONE), + EventHandler::Conditional(Box::new(AutocompleteDownHandler { + shared: shared.clone(), + })), + ); + + // Tab - select suggestion + rl.bind_sequence( + KeyEvent(KeyCode::Tab, Modifiers::NONE), + EventHandler::Conditional(Box::new(AutocompleteSelectHandler { + shared: shared.clone(), + })), + ); + + // Enter - also select suggestion when autocomplete is active + rl.bind_sequence( + KeyEvent(KeyCode::Enter, Modifiers::NONE), + EventHandler::Conditional(Box::new(AutocompleteSelectHandler { + shared: shared.clone(), + })), + ); + + // Escape - cancel suggestions + rl.bind_sequence( + KeyEvent(KeyCode::Esc, Modifiers::NONE), + EventHandler::Conditional(Box::new(AutocompleteEscapeHandler { + shared: shared.clone(), + })), + ); + + // Backspace - update suggestions + rl.bind_sequence( + KeyEvent(KeyCode::Backspace, Modifiers::NONE), + EventHandler::Conditional(Box::new(AutocompleteBackspaceHandler { + shared: shared.clone(), + })), + ); + while let Some(prompt) = request_rx.blocking_recv() { let response = handle_readline(&mut rl, &prompt); + + // Clear any suggestions after input is accepted (full reset) + shared.deactivate_full(); + if response_tx.send(response).is_err() { break; } @@ -80,10 +148,11 @@ impl InteractiveApp { let (chat_actor, event_rx) = ChatActorBuilder::tycode(workspace_roots, None, profile)?.build()?; + let terminal_width = terminal_size() + .map(|(Width(w), _)| w as usize) + .unwrap_or(80); + let mut formatter: Box = if compact { - let terminal_width = terminal_size() - .map(|(Width(w), _)| w as usize) - .unwrap_or(80); Box::new(CompactFormatter::new(terminal_width)) } else { Box::new(VerboseFormatter::new()) @@ -94,7 +163,7 @@ impl InteractiveApp { formatter.print_system(welcome_message); - let (readline_tx, readline_rx) = spawn_readline_thread(); + let (readline_tx, readline_rx) = spawn_readline_thread(terminal_width); Ok(Self { chat_actor, diff --git a/tycode-cli/src/main.rs b/tycode-cli/src/main.rs index 8a8879f..f1ac357 100644 --- a/tycode-cli/src/main.rs +++ b/tycode-cli/src/main.rs @@ -9,6 +9,7 @@ use tracing_subscriber::EnvFilter; mod auto; mod auto_driver; mod auto_pr; +mod autocomplete; mod commands; mod github; mod interactive_app;