diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 256bf26..ed49d71 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -211,7 +211,6 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo // Track multiline input let mut multiline_buffer = String::new(); let mut in_multiline = false; - let mut input_buffer = String::new(); // Main event loop loop { @@ -223,9 +222,6 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo context.percentage_used(), ); - // Update the displayed input buffer - tui.update_input(&input_buffer); - // Poll for keyboard events if event::poll(Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { @@ -238,8 +234,48 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo tui.exit(); break; } + // Emacs/bash-like shortcuts + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.cursor_home(); + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.cursor_end(); + } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.delete_word(); + } + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.delete_to_end(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Delete from beginning to cursor (similar to Ctrl-K but opposite direction) + let (input_buffer, cursor_pos) = tui.get_input_state(); + if cursor_pos > 0 { + let after = input_buffer.chars().skip(cursor_pos).collect::(); + tui.update_input(&after); + tui.cursor_home(); + } + } + KeyCode::Left => { + tui.cursor_left(); + } + KeyCode::Right => { + tui.cursor_right(); + } + KeyCode::Home => { + tui.cursor_home(); + } + KeyCode::End => { + tui.cursor_end(); + } + KeyCode::Delete => { + tui.delete_char(); + } KeyCode::Enter => { + let (input_buffer, _) = tui.get_input_state(); if !input_buffer.is_empty() { + // Clear the input for next command + tui.update_input(""); let trimmed = input_buffer.trim_end(); // Check if line ends with backslash for continuation @@ -249,7 +285,6 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo multiline_buffer.push_str(without_backslash); multiline_buffer.push('\n'); in_multiline = true; - input_buffer.clear(); tui.status("MULTILINE INPUT"); continue; } @@ -266,8 +301,6 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo input_buffer.clone() }; - input_buffer.clear(); - let input = final_input.trim().to_string(); if input.is_empty() { continue; @@ -305,10 +338,10 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo } } KeyCode::Char(c) => { - input_buffer.push(c); + tui.insert_char(c); } KeyCode::Backspace => { - input_buffer.pop(); + tui.backspace(); } KeyCode::Up => { tui.scroll_up(); @@ -322,11 +355,11 @@ async fn run_interactive_retro(config: Config, show_prompt: bool, show_code: boo KeyCode::PageDown => { tui.scroll_page_down(); } - KeyCode::Home => { - tui.scroll_home(); + KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.scroll_home(); // Ctrl+Home for scrolling to top } - KeyCode::End => { - tui.scroll_end(); + KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => { + tui.scroll_end(); // Ctrl+End for scrolling to bottom } _ => {} } diff --git a/crates/g3-cli/src/retro_tui.rs b/crates/g3-cli/src/retro_tui.rs index 4483f0e..e6c81fd 100644 --- a/crates/g3-cli/src/retro_tui.rs +++ b/crates/g3-cli/src/retro_tui.rs @@ -65,6 +65,8 @@ pub enum TuiMessage { struct TerminalState { /// Current input buffer input_buffer: String, + /// Cursor position in input buffer (for editing) + cursor_position: usize, /// Output history output_history: Vec, /// Scroll position in output @@ -115,6 +117,7 @@ impl TerminalState { fn new() -> Self { Self { input_buffer: String::new(), + cursor_position: 0, output_history: vec![ "WEYLAND-YUTANI SYSTEMS".to_string(), "MU/TH/UR 6000 - INTERFACE 2.4.1".to_string(), @@ -380,6 +383,12 @@ impl RetroTui { // Set animation target based on processing state state.activity_animation_target = if state.is_processing { 1.0 } else { 0.0 }; + // Clear input buffer when entering PROCESSING mode + if !was_processing && state.is_processing { + state.input_buffer.clear(); + state.cursor_position = 0; + } + // Remove cursor when exiting PROCESSING mode if was_processing && !state.is_processing { if let Some(last) = state.output_history.last_mut() { @@ -543,7 +552,7 @@ impl RetroTui { } // Draw header/input area - Self::draw_input_area(f, chunks[0], &state.input_buffer, state.cursor_blink); + Self::draw_input_area(f, chunks[0], &state.input_buffer, state.cursor_position, state.cursor_blink, state.is_processing); // Draw main output area Self::draw_output_area(f, chunks[1], &state.output_history, state.scroll_offset); @@ -575,15 +584,63 @@ impl RetroTui { } /// Draw the input area with prompt - fn draw_input_area(f: &mut Frame, area: Rect, input_buffer: &str, cursor_blink: bool) { - // Show the actual input buffer content with prompt - let input_text = if cursor_blink { - format!("g3> {}█", input_buffer) + fn draw_input_area(f: &mut Frame, area: Rect, input_buffer: &str, cursor_position: usize, cursor_blink: bool, is_processing: bool) { + let prompt = "g3> "; + let prompt_len = prompt.len(); + + // Calculate available width for text (accounting for borders and prompt) + let available_width = area.width.saturating_sub(2).saturating_sub(prompt_len as u16) as usize; + + // Don't show cursor if processing + let show_cursor = !is_processing && cursor_blink; + + // Build the display text with cursor at the right position + let mut display_text = String::new(); + display_text.push_str(prompt); + + if input_buffer.is_empty() { + // Empty buffer - just show cursor if applicable + if show_cursor { + display_text.push('█'); + } } else { - format!("g3> {} ", input_buffer) - }; + // Calculate which part of the buffer to show (handle wrapping) + let total_cursor_pos = cursor_position; + + // Determine the window into the buffer we should show + let window_start = if total_cursor_pos > available_width - 1 { + // Cursor is beyond the visible area, scroll the view + total_cursor_pos - (available_width - 1) + } else { + 0 + }; + + // Get the visible portion of the buffer + let visible_buffer: String = input_buffer + .chars() + .skip(window_start) + .take(available_width) + .collect(); + + // Insert cursor at the appropriate position in the visible text + let visible_cursor_pos = cursor_position.saturating_sub(window_start); + + for (i, ch) in visible_buffer.chars().enumerate() { + if i == visible_cursor_pos && show_cursor { + display_text.push('█'); + // Don't add the character under the cursor if we're showing the block cursor + } else { + display_text.push(ch); + } + } + + // If cursor is at the end and we're showing it + if visible_cursor_pos == visible_buffer.len() && show_cursor { + display_text.push('█'); + } + } - let input = Paragraph::new(input_text) + let input = Paragraph::new(display_text) .style(Style::default().fg(TERMINAL_GREEN)) .block( Block::default() @@ -1092,9 +1149,119 @@ impl RetroTui { pub fn update_input(&self, input: &str) { if let Ok(mut state) = self.state.lock() { state.input_buffer = input.to_string(); + // Keep cursor at end when updating the whole buffer + state.cursor_position = input.len(); } } + /// Move cursor left + pub fn cursor_left(&self) { + if let Ok(mut state) = self.state.lock() { + if state.cursor_position > 0 { + state.cursor_position -= 1; + } + } + } + + /// Move cursor right + pub fn cursor_right(&self) { + if let Ok(mut state) = self.state.lock() { + if state.cursor_position < state.input_buffer.len() { + state.cursor_position += 1; + } + } + } + + /// Move cursor to beginning of line (Ctrl-A) + pub fn cursor_home(&self) { + if let Ok(mut state) = self.state.lock() { + state.cursor_position = 0; + } + } + + /// Move cursor to end of line (Ctrl-E) + pub fn cursor_end(&self) { + if let Ok(mut state) = self.state.lock() { + state.cursor_position = state.input_buffer.len(); + } + } + + /// Delete word before cursor (Ctrl-W) + pub fn delete_word(&self) { + if let Ok(mut state) = self.state.lock() { + if state.cursor_position > 0 { + // Find the start of the word to delete + let mut word_start = state.cursor_position; + let chars: Vec = state.input_buffer.chars().collect(); + + // Skip trailing spaces + while word_start > 0 && chars[word_start - 1].is_whitespace() { + word_start -= 1; + } + + // Find word boundary + while word_start > 0 && !chars[word_start - 1].is_whitespace() { + word_start -= 1; + } + + // Remove the word + let before = state.input_buffer.chars().take(word_start).collect::(); + let after = state.input_buffer.chars().skip(state.cursor_position).collect::(); + state.input_buffer = format!("{}{}", before, after); + state.cursor_position = word_start; + } + } + } + + /// Delete from cursor to end of line (Ctrl-K) + pub fn delete_to_end(&self) { + if let Ok(mut state) = self.state.lock() { + state.input_buffer = state.input_buffer.chars().take(state.cursor_position).collect(); + } + } + + /// Get current input buffer and cursor position + pub fn get_input_state(&self) -> (String, usize) { + if let Ok(state) = self.state.lock() { + (state.input_buffer.clone(), state.cursor_position) + } else { + (String::new(), 0) + } + } + + /// Insert character at cursor position + pub fn insert_char(&self, ch: char) { + if let Ok(mut state) = self.state.lock() { + let before = state.input_buffer.chars().take(state.cursor_position).collect::(); + let after = state.input_buffer.chars().skip(state.cursor_position).collect::(); + state.input_buffer = format!("{}{}{}", before, ch, after); + state.cursor_position += 1; + } + } + + /// Delete character at cursor position (Delete key) + pub fn delete_char(&self) { + if let Ok(mut state) = self.state.lock() { + if state.cursor_position < state.input_buffer.len() { + let before = state.input_buffer.chars().take(state.cursor_position).collect::(); + let after = state.input_buffer.chars().skip(state.cursor_position + 1).collect::(); + state.input_buffer = format!("{}{}", before, after); + } + } + } + + /// Delete character before cursor (Backspace) + pub fn backspace(&self) { + if let Ok(mut state) = self.state.lock() { + if state.cursor_position > 0 { + let before = state.input_buffer.chars().take(state.cursor_position - 1).collect::(); + let after = state.input_buffer.chars().skip(state.cursor_position).collect::(); + state.input_buffer = format!("{}{}", before, after); + state.cursor_position -= 1; + } + } + } + /// Handle scrolling pub fn scroll_up(&self) { if let Ok(mut state) = self.state.lock() {