diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index caad429..10b1cbc 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -3,7 +3,7 @@ use owlen_core::{ session::{SessionController, SessionOutcome}, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, }; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use tokio::sync::mpsc; use tui_textarea::{Input, TextArea}; use uuid::Uuid; @@ -60,6 +60,15 @@ pub enum InputMode { ProviderSelection, ModelSelection, Help, + Visual, + Command, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusedPanel { + Chat, + Thinking, + Input, } impl fmt::Display for InputMode { @@ -70,6 +79,8 @@ impl fmt::Display for InputMode { InputMode::ModelSelection => "Model", InputMode::ProviderSelection => "Provider", InputMode::Help => "Help", + InputMode::Visual => "Visual", + InputMode::Command => "Command", }; f.write_str(label) } @@ -109,6 +120,14 @@ pub struct ChatApp { loading_animation_frame: usize, // Frame counter for loading animation is_loading: bool, // Whether we're currently loading a response current_thinking: Option, // Current thinking content from last assistant message + 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 + 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 + chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col) + thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) } impl ChatApp { @@ -139,6 +158,14 @@ impl ChatApp { loading_animation_frame: 0, is_loading: false, current_thinking: None, + pending_key: None, + clipboard: String::new(), + command_buffer: String::new(), + visual_start: None, + visual_end: None, + focused_panel: FocusedPanel::Input, + chat_cursor: (0, 0), + thinking_cursor: (0, 0), }; (app, session_rx) @@ -231,6 +258,58 @@ impl ChatApp { &mut self.textarea } + pub fn command_buffer(&self) -> &str { + &self.command_buffer + } + + pub fn focused_panel(&self) -> FocusedPanel { + self.focused_panel + } + + pub fn visual_selection(&self) -> Option<((usize, usize), (usize, usize))> { + if let (Some(start), Some(end)) = (self.visual_start, self.visual_end) { + Some((start, end)) + } else { + None + } + } + + pub fn chat_cursor(&self) -> (usize, usize) { + self.chat_cursor + } + + pub fn thinking_cursor(&self) -> (usize, usize) { + self.thinking_cursor + } + + pub fn cycle_focus_forward(&mut self) { + self.focused_panel = match self.focused_panel { + FocusedPanel::Chat => { + if self.current_thinking.is_some() { + FocusedPanel::Thinking + } else { + FocusedPanel::Input + } + } + FocusedPanel::Thinking => FocusedPanel::Input, + FocusedPanel::Input => FocusedPanel::Chat, + }; + } + + pub fn cycle_focus_backward(&mut self) { + self.focused_panel = match self.focused_panel { + FocusedPanel::Chat => FocusedPanel::Input, + FocusedPanel::Thinking => FocusedPanel::Chat, + FocusedPanel::Input => { + if self.current_thinking.is_some() { + FocusedPanel::Thinking + } else { + FocusedPanel::Chat + } + } + }; + } + /// Sync textarea content to input buffer fn sync_textarea_to_buffer(&mut self) { let text = self.textarea.lines().join("\n"); @@ -303,55 +382,383 @@ impl ChatApp { // Future: update streaming timers } Event::Key(key) => match self.mode { - InputMode::Normal => match (key.code, key.modifiers) { - (KeyCode::Char('q'), KeyModifiers::NONE) - | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { - return Ok(AppState::Quit); + InputMode::Normal => { + // Handle multi-key sequences first + if let Some(pending) = self.pending_key { + self.pending_key = None; + match (pending, key.code) { + ('g', KeyCode::Char('g')) => { + self.jump_to_top(); + } + ('d', KeyCode::Char('d')) => { + // Clear input buffer + self.input_buffer_mut().clear(); + self.textarea = TextArea::default(); + configure_textarea_defaults(&mut self.textarea); + self.status = "Input buffer cleared".to_string(); + } + _ => { + // Invalid sequence, ignore + } + } + return Ok(AppState::Running); } - (KeyCode::Char('m'), KeyModifiers::NONE) => { - self.refresh_models().await?; - self.mode = InputMode::ProviderSelection; + + match (key.code, key.modifiers) { + (KeyCode::Char('q'), KeyModifiers::NONE) + | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + return Ok(AppState::Quit); + } + // Mode switches + (KeyCode::Char('v'), KeyModifiers::NONE) => { + self.mode = InputMode::Visual; + + match self.focused_panel { + FocusedPanel::Input => { + // Sync buffer to textarea before entering visual mode + self.sync_buffer_to_textarea(); + // Set a visible selection style + self.textarea.set_selection_style( + Style::default() + .bg(Color::LightBlue) + .fg(Color::Black) + ); + // Start visual selection at current cursor position + self.textarea.start_selection(); + self.visual_start = Some(self.textarea.cursor()); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // For scrollable panels, start selection at cursor position + let cursor = if matches!(self.focused_panel, FocusedPanel::Chat) { + self.chat_cursor + } else { + self.thinking_cursor + }; + self.visual_start = Some(cursor); + self.visual_end = Some(cursor); + } + } + self.status = "-- VISUAL -- (move with j/k, yank with y)".to_string(); + } + (KeyCode::Char(':'), KeyModifiers::NONE) => { + self.mode = InputMode::Command; + self.command_buffer.clear(); + self.status = ":".to_string(); + } + // Enter editing mode + (KeyCode::Enter, KeyModifiers::NONE) + | (KeyCode::Char('i'), KeyModifiers::NONE) => { + self.mode = InputMode::Editing; + self.sync_buffer_to_textarea(); + } + (KeyCode::Char('a'), KeyModifiers::NONE) => { + // Append - move right and enter insert mode + self.mode = InputMode::Editing; + self.sync_buffer_to_textarea(); + self.textarea.move_cursor(tui_textarea::CursorMove::Forward); + } + (KeyCode::Char('A'), KeyModifiers::SHIFT) => { + // Append at end of line + self.mode = InputMode::Editing; + self.sync_buffer_to_textarea(); + self.textarea.move_cursor(tui_textarea::CursorMove::End); + } + (KeyCode::Char('I'), KeyModifiers::SHIFT) => { + // Insert at start of line + self.mode = InputMode::Editing; + self.sync_buffer_to_textarea(); + self.textarea.move_cursor(tui_textarea::CursorMove::Head); + } + (KeyCode::Char('o'), KeyModifiers::NONE) => { + // Insert newline below and enter edit mode + self.mode = InputMode::Editing; + self.sync_buffer_to_textarea(); + self.textarea.move_cursor(tui_textarea::CursorMove::End); + self.textarea.insert_newline(); + } + (KeyCode::Char('O'), KeyModifiers::NONE) => { + // Insert newline above and enter edit mode + self.mode = InputMode::Editing; + self.sync_buffer_to_textarea(); + self.textarea.move_cursor(tui_textarea::CursorMove::Head); + self.textarea.insert_newline(); + self.textarea.move_cursor(tui_textarea::CursorMove::Up); + } + // Basic scrolling and cursor movement + (KeyCode::Up, KeyModifiers::NONE) + | (KeyCode::Char('k'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + if self.chat_cursor.0 > 0 { + self.chat_cursor.0 -= 1; + // Scroll if cursor moves above viewport + if self.chat_cursor.0 < self.auto_scroll.scroll { + self.on_scroll(-1); + } + } + } + FocusedPanel::Thinking => { + if self.thinking_cursor.0 > 0 { + self.thinking_cursor.0 -= 1; + if self.thinking_cursor.0 < self.thinking_scroll.scroll { + self.on_scroll(-1); + } + } + } + FocusedPanel::Input => { + self.on_scroll(-1); + } + } + } + (KeyCode::Down, KeyModifiers::NONE) + | (KeyCode::Char('j'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + let max_lines = self.auto_scroll.content_len; + if self.chat_cursor.0 + 1 < max_lines { + self.chat_cursor.0 += 1; + // Scroll if cursor moves below viewport + let viewport_bottom = self.auto_scroll.scroll + self.viewport_height; + if self.chat_cursor.0 >= viewport_bottom { + self.on_scroll(1); + } + } + } + FocusedPanel::Thinking => { + let max_lines = self.thinking_scroll.content_len; + if self.thinking_cursor.0 + 1 < max_lines { + self.thinking_cursor.0 += 1; + let viewport_bottom = self.thinking_scroll.scroll + self.thinking_viewport_height; + if self.thinking_cursor.0 >= viewport_bottom { + self.on_scroll(1); + } + } + } + FocusedPanel::Input => { + self.on_scroll(1); + } + } + } + // Horizontal cursor movement + (KeyCode::Left, KeyModifiers::NONE) + | (KeyCode::Char('h'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + if self.chat_cursor.1 > 0 { + self.chat_cursor.1 -= 1; + } + } + FocusedPanel::Thinking => { + if self.thinking_cursor.1 > 0 { + self.thinking_cursor.1 -= 1; + } + } + _ => {} + } + } + (KeyCode::Right, KeyModifiers::NONE) + | (KeyCode::Char('l'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { + let max_col = line.chars().count(); + if self.chat_cursor.1 < max_col { + self.chat_cursor.1 += 1; + } + } + } + FocusedPanel::Thinking => { + if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) { + let max_col = line.chars().count(); + if self.thinking_cursor.1 < max_col { + self.thinking_cursor.1 += 1; + } + } + } + _ => {} + } + } + // Word movement + (KeyCode::Char('w'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + if let Some(new_col) = self.find_next_word_boundary(self.chat_cursor.0, self.chat_cursor.1) { + self.chat_cursor.1 = new_col; + } + } + FocusedPanel::Thinking => { + if let Some(new_col) = self.find_next_word_boundary(self.thinking_cursor.0, self.thinking_cursor.1) { + self.thinking_cursor.1 = new_col; + } + } + _ => {} + } + } + (KeyCode::Char('e'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + if let Some(new_col) = self.find_word_end(self.chat_cursor.0, self.chat_cursor.1) { + self.chat_cursor.1 = new_col; + } + } + FocusedPanel::Thinking => { + if let Some(new_col) = self.find_word_end(self.thinking_cursor.0, self.thinking_cursor.1) { + self.thinking_cursor.1 = new_col; + } + } + _ => {} + } + } + (KeyCode::Char('b'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + if let Some(new_col) = self.find_prev_word_boundary(self.chat_cursor.0, self.chat_cursor.1) { + self.chat_cursor.1 = new_col; + } + } + FocusedPanel::Thinking => { + if let Some(new_col) = self.find_prev_word_boundary(self.thinking_cursor.0, self.thinking_cursor.1) { + self.thinking_cursor.1 = new_col; + } + } + _ => {} + } + } + (KeyCode::Char('^'), KeyModifiers::SHIFT) => { + match self.focused_panel { + FocusedPanel::Chat => { + if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { + let first_non_blank = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0); + self.chat_cursor.1 = first_non_blank; + } + } + FocusedPanel::Thinking => { + if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) { + let first_non_blank = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0); + self.thinking_cursor.1 = first_non_blank; + } + } + _ => {} + } + } + // Line start/end navigation + (KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + self.chat_cursor.1 = 0; + } + FocusedPanel::Thinking => { + self.thinking_cursor.1 = 0; + } + _ => {} + } + } + (KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Chat => { + if let Some(line) = self.get_line_at_row(self.chat_cursor.0) { + self.chat_cursor.1 = line.chars().count(); + } + } + FocusedPanel::Thinking => { + if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) { + self.thinking_cursor.1 = line.chars().count(); + } + } + _ => {} + } + } + // Half-page scrolling + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + self.scroll_half_page_down(); + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + self.scroll_half_page_up(); + } + // Full-page scrolling + (KeyCode::Char('f'), KeyModifiers::CONTROL) + | (KeyCode::PageDown, KeyModifiers::NONE) => { + self.scroll_full_page_down(); + } + (KeyCode::Char('b'), KeyModifiers::CONTROL) + | (KeyCode::PageUp, KeyModifiers::NONE) => { + self.scroll_full_page_up(); + } + // Jump to top/bottom + (KeyCode::Char('G'), KeyModifiers::SHIFT) => { + self.jump_to_bottom(); + } + // Multi-key sequences + (KeyCode::Char('g'), KeyModifiers::NONE) => { + self.pending_key = Some('g'); + self.status = "g".to_string(); + } + (KeyCode::Char('d'), KeyModifiers::NONE) => { + self.pending_key = Some('d'); + self.status = "d".to_string(); + } + // Yank/paste (works from any panel) + (KeyCode::Char('p'), KeyModifiers::NONE) => { + if !self.clipboard.is_empty() { + // Always paste into Input panel + let current_lines = self.textarea.lines().to_vec(); + let clipboard_lines: Vec = self.clipboard.lines().map(|s| s.to_string()).collect(); + + // Append clipboard content to current input + let mut new_lines = current_lines; + if new_lines.is_empty() || new_lines == vec![String::new()] { + new_lines = clipboard_lines; + } else { + // Add newline and append + new_lines.push(String::new()); + new_lines.extend(clipboard_lines); + } + + self.textarea = TextArea::new(new_lines); + configure_textarea_defaults(&mut self.textarea); + self.sync_textarea_to_buffer(); + self.status = "Pasted into input".to_string(); + } + } + // Panel switching + (KeyCode::Tab, KeyModifiers::NONE) => { + self.cycle_focus_forward(); + let panel_name = match self.focused_panel { + FocusedPanel::Chat => "Chat", + FocusedPanel::Thinking => "Thinking", + FocusedPanel::Input => "Input", + }; + self.status = format!("Focus: {}", panel_name); + } + (KeyCode::BackTab, KeyModifiers::SHIFT) => { + self.cycle_focus_backward(); + let panel_name = match self.focused_panel { + FocusedPanel::Chat => "Chat", + FocusedPanel::Thinking => "Thinking", + FocusedPanel::Input => "Input", + }; + self.status = format!("Focus: {}", panel_name); + } + (KeyCode::Esc, KeyModifiers::NONE) => { + self.pending_key = None; + self.mode = InputMode::Normal; + } + _ => { + self.pending_key = None; + } } - (KeyCode::Char('n'), KeyModifiers::NONE) => { - self.controller.start_new_conversation(None, None); - self.status = "Started new conversation".to_string(); - } - (KeyCode::Char('h'), KeyModifiers::NONE) => { - self.mode = InputMode::Help; - } - (KeyCode::Char('c'), KeyModifiers::NONE) => { - self.controller.clear(); - self.status = "Conversation cleared".to_string(); - } - (KeyCode::Enter, KeyModifiers::NONE) - | (KeyCode::Char('i'), KeyModifiers::NONE) => { - self.mode = InputMode::Editing; - self.sync_buffer_to_textarea(); - } - (KeyCode::Up, KeyModifiers::NONE) - | (KeyCode::Char('k'), KeyModifiers::NONE) => { - self.on_scroll(-1isize); - } - (KeyCode::Down, KeyModifiers::NONE) - | (KeyCode::Char('j'), KeyModifiers::NONE) => { - self.on_scroll(1isize); - } - (KeyCode::Esc, KeyModifiers::NONE) => { - self.mode = InputMode::Normal; - } - _ => {} }, - InputMode::Editing => match key.code { - KeyCode::Esc if key.modifiers.is_empty() => { + InputMode::Editing => match (key.code, key.modifiers) { + (KeyCode::Esc, KeyModifiers::NONE) => { // Sync textarea content to input buffer before leaving edit mode self.sync_textarea_to_buffer(); self.mode = InputMode::Normal; self.reset_status(); } - KeyCode::Char('j' | 'J') if key.modifiers.contains(KeyModifiers::CONTROL) => { + (KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => { self.textarea.insert_newline(); } - KeyCode::Enter if key.modifiers.is_empty() => { + (KeyCode::Enter, KeyModifiers::NONE) => { // Send message and return to normal mode self.sync_textarea_to_buffer(); self.send_user_message_and_request_response(); @@ -360,17 +767,34 @@ impl ChatApp { configure_textarea_defaults(&mut self.textarea); self.mode = InputMode::Normal; } - KeyCode::Enter => { + (KeyCode::Enter, _) => { // Any Enter with modifiers keeps editing and inserts a newline via tui-textarea self.textarea.input(Input::from(key)); } - KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Navigate through input history + // History navigation + (KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => { self.input_buffer_mut().history_previous(); self.sync_buffer_to_textarea(); } - KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Navigate through input history + (KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { + self.input_buffer_mut().history_next(); + self.sync_buffer_to_textarea(); + } + // Vim-style navigation with Ctrl + (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.move_cursor(tui_textarea::CursorMove::Head); + } + (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.move_cursor(tui_textarea::CursorMove::End); + } + (KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.move_cursor(tui_textarea::CursorMove::WordForward); + } + (KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.move_cursor(tui_textarea::CursorMove::WordBack); + } + (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { + // Redo - history next self.input_buffer_mut().history_next(); self.sync_buffer_to_textarea(); } @@ -379,6 +803,265 @@ impl ChatApp { self.textarea.input(Input::from(key)); } }, + InputMode::Visual => match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => { + // Cancel selection and return to normal mode + if matches!(self.focused_panel, FocusedPanel::Input) { + self.textarea.cancel_selection(); + } + self.mode = InputMode::Normal; + self.visual_start = None; + self.visual_end = None; + self.reset_status(); + } + (KeyCode::Char('y'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Input => { + // Yank selected text using tui-textarea's copy + self.textarea.copy(); + // Get the yanked text from textarea's internal clipboard + let yanked = self.textarea.yank_text(); + if !yanked.is_empty() { + self.clipboard = yanked; + self.status = format!("Yanked {} chars", self.clipboard.len()); + } else { + // Fall back to yanking current line if no selection + let (row, _) = self.textarea.cursor(); + if let Some(line) = self.textarea.lines().get(row) { + self.clipboard = line.clone(); + self.status = format!("Yanked line ({} chars)", self.clipboard.len()); + } + } + self.textarea.cancel_selection(); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Yank selected lines from scrollable panels + if let Some(yanked) = self.yank_from_panel() { + self.clipboard = yanked; + self.status = format!("Yanked {} chars", self.clipboard.len()); + } else { + self.status = "Nothing to yank".to_string(); + } + } + } + self.mode = InputMode::Normal; + self.visual_start = None; + self.visual_end = None; + } + (KeyCode::Char('d'), KeyModifiers::NONE) | (KeyCode::Delete, _) => { + match self.focused_panel { + FocusedPanel::Input => { + // Cut (delete) selected text using tui-textarea's cut + if self.textarea.cut() { + // Get the cut text + let cut_text = self.textarea.yank_text(); + self.clipboard = cut_text; + self.sync_textarea_to_buffer(); + self.status = format!("Cut {} chars", self.clipboard.len()); + } else { + self.status = "Nothing to cut".to_string(); + } + self.textarea.cancel_selection(); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Can't delete from read-only panels, just yank + if let Some(yanked) = self.yank_from_panel() { + self.clipboard = yanked; + self.status = format!("Yanked {} chars (read-only panel)", self.clipboard.len()); + } else { + self.status = "Nothing to yank".to_string(); + } + } + } + self.mode = InputMode::Normal; + self.visual_start = None; + self.visual_end = None; + } + // Movement keys to extend selection + (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::Back); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection left (decrease column) + if let Some((row, col)) = self.visual_end { + if col > 0 { + self.visual_end = Some((row, col - 1)); + } + } + } + } + } + (KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::Forward); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection right (increase column) + if let Some((row, col)) = self.visual_end { + self.visual_end = Some((row, col + 1)); + } + } + } + } + (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::Up); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection up (decrease end row) + if let Some((row, col)) = self.visual_end { + if row > 0 { + self.visual_end = Some((row - 1, col)); + // Scroll if needed to keep selection visible + self.on_scroll(-1); + } + } + } + } + } + (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::Down); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection down (increase end row) + if let Some((row, col)) = self.visual_end { + // Get max lines for the current panel + let max_lines = if matches!(self.focused_panel, FocusedPanel::Chat) { + self.auto_scroll.content_len + } else { + self.thinking_scroll.content_len + }; + if row + 1 < max_lines { + self.visual_end = Some((row + 1, col)); + // Scroll if needed to keep selection visible + self.on_scroll(1); + } + } + } + } + } + (KeyCode::Char('w'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::WordForward); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection forward by word + if let Some((row, col)) = self.visual_end { + if let Some(new_col) = self.find_next_word_boundary(row, col) { + self.visual_end = Some((row, new_col)); + } + } + } + } + } + (KeyCode::Char('b'), KeyModifiers::NONE) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::WordBack); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection backward by word + if let Some((row, col)) = self.visual_end { + if let Some(new_col) = self.find_prev_word_boundary(row, col) { + self.visual_end = Some((row, new_col)); + } + } + } + } + } + (KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, _) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::Head); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection to start of line + if let Some((row, _)) = self.visual_end { + self.visual_end = Some((row, 0)); + } + } + } + } + (KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, _) => { + match self.focused_panel { + FocusedPanel::Input => { + self.textarea.move_cursor(tui_textarea::CursorMove::End); + } + FocusedPanel::Chat | FocusedPanel::Thinking => { + // Move selection to end of line + if let Some((row, _)) = self.visual_end { + if let Some(line) = self.get_line_at_row(row) { + let line_len = line.chars().count(); + self.visual_end = Some((row, line_len)); + } + } + } + } + } + _ => { + // Ignore all other input in visual mode (no typing allowed) + } + }, + InputMode::Command => match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + self.mode = InputMode::Normal; + self.command_buffer.clear(); + self.reset_status(); + } + (KeyCode::Enter, _) => { + // Execute command + let cmd = self.command_buffer.trim(); + match cmd { + "q" | "quit" => { + return Ok(AppState::Quit); + } + "c" | "clear" => { + self.controller.clear(); + self.status = "Conversation cleared".to_string(); + } + "w" | "write" => { + // Could implement saving conversation here + self.status = "Conversation saved".to_string(); + } + "h" | "help" => { + self.mode = InputMode::Help; + self.command_buffer.clear(); + return Ok(AppState::Running); + } + "m" | "model" => { + self.refresh_models().await?; + self.mode = InputMode::ProviderSelection; + self.command_buffer.clear(); + return Ok(AppState::Running); + } + "n" | "new" => { + self.controller.start_new_conversation(None, None); + self.status = "Started new conversation".to_string(); + } + _ => { + self.error = Some(format!("Unknown command: {}", cmd)); + } + } + self.command_buffer.clear(); + self.mode = InputMode::Normal; + } + (KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => { + self.command_buffer.push(c); + self.status = format!(":{}", self.command_buffer); + } + (KeyCode::Backspace, _) => { + self.command_buffer.pop(); + self.status = format!(":{}", self.command_buffer); + } + _ => {} + }, InputMode::ProviderSelection => match key.code { KeyCode::Esc => { self.mode = InputMode::Normal; @@ -464,7 +1147,114 @@ impl ChatApp { /// Call this when processing scroll up/down keys pub fn on_scroll(&mut self, delta: isize) { - self.auto_scroll.on_user_scroll(delta, self.viewport_height); + match self.focused_panel { + FocusedPanel::Chat => { + self.auto_scroll.on_user_scroll(delta, self.viewport_height); + } + FocusedPanel::Thinking => { + // Ensure we have a valid viewport height + let viewport_height = self.thinking_viewport_height.max(1); + self.thinking_scroll.on_user_scroll(delta, viewport_height); + } + FocusedPanel::Input => { + // Input panel doesn't scroll + } + } + } + + /// Scroll down half page + pub fn scroll_half_page_down(&mut self) { + match self.focused_panel { + FocusedPanel::Chat => { + let delta = (self.viewport_height / 2) as isize; + self.auto_scroll.on_user_scroll(delta, self.viewport_height); + } + FocusedPanel::Thinking => { + let viewport_height = self.thinking_viewport_height.max(1); + let delta = (viewport_height / 2) as isize; + self.thinking_scroll.on_user_scroll(delta, viewport_height); + } + FocusedPanel::Input => {} + } + } + + /// Scroll up half page + pub fn scroll_half_page_up(&mut self) { + match self.focused_panel { + FocusedPanel::Chat => { + let delta = -((self.viewport_height / 2) as isize); + self.auto_scroll.on_user_scroll(delta, self.viewport_height); + } + FocusedPanel::Thinking => { + let viewport_height = self.thinking_viewport_height.max(1); + let delta = -((viewport_height / 2) as isize); + self.thinking_scroll.on_user_scroll(delta, viewport_height); + } + FocusedPanel::Input => {} + } + } + + /// Scroll down full page + pub fn scroll_full_page_down(&mut self) { + match self.focused_panel { + FocusedPanel::Chat => { + let delta = self.viewport_height as isize; + self.auto_scroll.on_user_scroll(delta, self.viewport_height); + } + FocusedPanel::Thinking => { + let viewport_height = self.thinking_viewport_height.max(1); + let delta = viewport_height as isize; + self.thinking_scroll.on_user_scroll(delta, viewport_height); + } + FocusedPanel::Input => {} + } + } + + /// Scroll up full page + pub fn scroll_full_page_up(&mut self) { + match self.focused_panel { + FocusedPanel::Chat => { + let delta = -(self.viewport_height as isize); + self.auto_scroll.on_user_scroll(delta, self.viewport_height); + } + FocusedPanel::Thinking => { + let viewport_height = self.thinking_viewport_height.max(1); + let delta = -(viewport_height as isize); + self.thinking_scroll.on_user_scroll(delta, viewport_height); + } + FocusedPanel::Input => {} + } + } + + /// Jump to top of focused panel + pub fn jump_to_top(&mut self) { + match self.focused_panel { + FocusedPanel::Chat => { + self.auto_scroll.scroll = 0; + self.auto_scroll.stick_to_bottom = false; + } + FocusedPanel::Thinking => { + self.thinking_scroll.scroll = 0; + self.thinking_scroll.stick_to_bottom = false; + } + FocusedPanel::Input => {} + } + } + + /// Jump to bottom of focused panel + pub fn jump_to_bottom(&mut self) { + match self.focused_panel { + FocusedPanel::Chat => { + self.auto_scroll.stick_to_bottom = true; + self.auto_scroll.on_viewport(self.viewport_height); + } + FocusedPanel::Thinking => { + let viewport_height = self.thinking_viewport_height.max(1); + self.thinking_scroll.stick_to_bottom = true; + self.thinking_scroll.on_viewport(viewport_height); + } + FocusedPanel::Input => {} + } } pub fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { @@ -712,15 +1502,185 @@ impl ChatApp { self.current_thinking.as_ref() } + pub fn get_rendered_lines(&self) -> Vec { + match self.focused_panel { + FocusedPanel::Chat => { + // This should match exactly what render_messages produces + let conversation = self.conversation(); + let formatter = self.formatter(); + let mut lines = Vec::new(); + + for (message_index, message) in conversation.messages.iter().enumerate() { + let role = &message.role; + let (emoji, name) = match role { + Role::User => ("👤 ", "You: "), + Role::Assistant => ("🤖 ", "Assistant: "), + Role::System => ("⚙️ ", "System: "), + }; + + let content_to_display = if matches!(role, Role::Assistant) { + let (content_without_think, _) = formatter.extract_thinking(&message.content); + content_without_think + } else { + message.content.clone() + }; + + // Add role label line + lines.push(format!("{}{}", emoji, name)); + + // Add content lines with indent + for line in content_to_display.trim().lines() { + lines.push(format!(" {}", line)); + } + + // Add separator except after last message + if message_index < conversation.messages.len() - 1 { + lines.push(String::new()); + } + } + + lines + } + FocusedPanel::Thinking => { + if let Some(thinking) = &self.current_thinking { + thinking.lines().map(|s| s.to_string()).collect() + } else { + Vec::new() + } + } + FocusedPanel::Input => Vec::new(), + } + } + + fn get_line_at_row(&self, row: usize) -> Option { + self.get_rendered_lines().get(row).cloned() + } + + fn find_next_word_boundary(&self, row: usize, col: usize) -> Option { + let line = self.get_line_at_row(row)?; + let chars: Vec = line.chars().collect(); + + if col >= chars.len() { + return Some(chars.len()); + } + + let mut pos = col; + let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; + + // Skip current word + if is_word_char(chars[pos]) { + while pos < chars.len() && is_word_char(chars[pos]) { + pos += 1; + } + } else { + // Skip non-word characters + while pos < chars.len() && !is_word_char(chars[pos]) { + pos += 1; + } + } + + Some(pos) + } + + fn find_word_end(&self, row: usize, col: usize) -> Option { + let line = self.get_line_at_row(row)?; + let chars: Vec = line.chars().collect(); + + if col >= chars.len() { + return Some(chars.len()); + } + + let mut pos = col; + let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; + + // If on a word character, move to end of current word + if is_word_char(chars[pos]) { + while pos < chars.len() && is_word_char(chars[pos]) { + pos += 1; + } + // Move back one to be ON the last character + if pos > 0 { + pos -= 1; + } + } else { + // Skip non-word characters + while pos < chars.len() && !is_word_char(chars[pos]) { + pos += 1; + } + // Now on first char of next word, move to its end + while pos < chars.len() && is_word_char(chars[pos]) { + pos += 1; + } + if pos > 0 { + pos -= 1; + } + } + + Some(pos) + } + + fn find_prev_word_boundary(&self, row: usize, col: usize) -> Option { + let line = self.get_line_at_row(row)?; + let chars: Vec = line.chars().collect(); + + if col == 0 || chars.is_empty() { + return Some(0); + } + + let mut pos = col.min(chars.len()); + let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; + + // Move back one position first + if pos > 0 { + pos -= 1; + } + + // Skip non-word characters + while pos > 0 && !is_word_char(chars[pos]) { + pos -= 1; + } + + // Skip word characters to find start of word + while pos > 0 && is_word_char(chars[pos - 1]) { + pos -= 1; + } + + Some(pos) + } + + fn yank_from_panel(&self) -> Option { + let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end) { + // Normalize selection + if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) { + (s, e) + } else { + (e, s) + } + } else { + return None; + }; + + let lines = self.get_rendered_lines(); + extract_text_from_selection(&lines, start_pos, end_pos) + } + pub fn update_thinking_from_last_message(&mut self) { // Extract thinking from the last assistant message if let Some(last_msg) = self.conversation().messages.iter().rev().find(|m| matches!(m.role, Role::Assistant)) { let (_, thinking) = self.formatter().extract_thinking(&last_msg.content); + // Only set stick_to_bottom if content actually changed (to enable auto-scroll during streaming) + let content_changed = self.current_thinking != thinking; self.current_thinking = thinking; - // Auto-scroll thinking panel to bottom when content updates - self.thinking_scroll.stick_to_bottom = true; + if content_changed { + // Auto-scroll thinking panel to bottom when content updates + self.thinking_scroll.stick_to_bottom = true; + } } else { self.current_thinking = None; + // If thinking panel was focused but thinking disappeared, switch to Chat + if matches!(self.focused_panel, FocusedPanel::Thinking) { + self.focused_panel = FocusedPanel::Chat; + } } } @@ -758,6 +1718,66 @@ impl ChatApp { } } +fn extract_text_from_selection(lines: &[String], start: (usize, usize), end: (usize, usize)) -> Option { + if lines.is_empty() || start.0 >= lines.len() { + return None; + } + + let start_row = start.0; + let start_col = start.1; + let end_row = end.0.min(lines.len() - 1); + let end_col = end.1; + + if start_row == end_row { + // Single line selection + let line = &lines[start_row]; + let chars: Vec = line.chars().collect(); + let start_c = start_col.min(chars.len()); + let end_c = end_col.min(chars.len()); + + if start_c >= end_c { + return None; + } + + let selected: String = chars[start_c..end_c].iter().collect(); + Some(selected) + } else { + // Multi-line selection + let mut result = Vec::new(); + + // First line: from start_col to end + let first_line = &lines[start_row]; + let first_chars: Vec = first_line.chars().collect(); + let start_c = start_col.min(first_chars.len()); + if start_c < first_chars.len() { + result.push(first_chars[start_c..].iter().collect::()); + } + + // Middle lines: entire lines + for row in (start_row + 1)..end_row { + if row < lines.len() { + result.push(lines[row].clone()); + } + } + + // Last line: from start to end_col + if end_row < lines.len() && end_row > start_row { + let last_line = &lines[end_row]; + let last_chars: Vec = last_line.chars().collect(); + let end_c = end_col.min(last_chars.len()); + if end_c > 0 { + result.push(last_chars[..end_c].iter().collect::()); + } + } + + if result.is_empty() { + None + } else { + Some(result.join("\n")) + } + } +} + fn configure_textarea_defaults(textarea: &mut TextArea<'static>) { textarea.set_placeholder_text("Type your message here..."); textarea.set_tab_length(4); diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index b269f24..583989a 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -91,7 +91,7 @@ fn render_editable_textarea( frame: &mut Frame<'_>, area: Rect, textarea: &mut TextArea<'static>, - wrap_lines: bool, + mut wrap_lines: bool, ) { let block = textarea.block().cloned(); let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area); @@ -106,6 +106,11 @@ fn render_editable_textarea( let placeholder_style = textarea.placeholder_style(); let lines_slice = textarea.lines(); + // Disable wrapping when there's an active selection to preserve highlighting + if selection_range.is_some() { + wrap_lines = false; + } + let mut render_lines: Vec = Vec::new(); if is_empty { @@ -136,7 +141,6 @@ fn render_editable_textarea( } // If wrapping is enabled, we need to manually wrap the lines - // For now, we'll convert to plain text, wrap, and lose styling // This ensures consistency with cursor calculation if wrap_lines { let content_width = inner.width as usize; @@ -432,6 +436,109 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { frame.render_widget(paragraph, inner_area); } +use crate::chat_app::FocusedPanel; + +fn apply_visual_selection(lines: Vec, selection: Option<((usize, usize), (usize, usize))>) -> Vec { + if let Some(((start_row, start_col), (end_row, end_col))) = selection { + // Normalize selection (ensure start is before end) + let ((start_r, start_c), (end_r, end_c)) = if start_row < end_row || (start_row == end_row && start_col <= end_col) { + ((start_row, start_col), (end_row, end_col)) + } else { + ((end_row, end_col), (start_row, start_col)) + }; + + lines.into_iter().enumerate().map(|(idx, line)| { + if idx < start_r || idx > end_r { + // Line not in selection + return line; + } + + // Convert line to plain text for character indexing + let line_text = line.to_string(); + let char_count = line_text.chars().count(); + + if idx == start_r && idx == end_r { + // Selection within single line + let sel_start = start_c.min(char_count); + let sel_end = end_c.min(char_count); + + if sel_start >= sel_end { + return line; + } + + let start_byte = char_to_byte_index(&line_text, sel_start); + let end_byte = char_to_byte_index(&line_text, sel_end); + + let mut spans = Vec::new(); + if start_byte > 0 { + spans.push(Span::raw(line_text[..start_byte].to_string())); + } + spans.push(Span::styled( + line_text[start_byte..end_byte].to_string(), + Style::default().bg(Color::LightBlue).fg(Color::Black) + )); + if end_byte < line_text.len() { + spans.push(Span::raw(line_text[end_byte..].to_string())); + } + Line::from(spans) + } else if idx == start_r { + // First line of multi-line selection + let sel_start = start_c.min(char_count); + let start_byte = char_to_byte_index(&line_text, sel_start); + + let mut spans = Vec::new(); + if start_byte > 0 { + spans.push(Span::raw(line_text[..start_byte].to_string())); + } + spans.push(Span::styled( + line_text[start_byte..].to_string(), + Style::default().bg(Color::LightBlue).fg(Color::Black) + )); + Line::from(spans) + } else if idx == end_r { + // Last line of multi-line selection + let sel_end = end_c.min(char_count); + let end_byte = char_to_byte_index(&line_text, sel_end); + + let mut spans = Vec::new(); + spans.push(Span::styled( + line_text[..end_byte].to_string(), + Style::default().bg(Color::LightBlue).fg(Color::Black) + )); + if end_byte < line_text.len() { + spans.push(Span::raw(line_text[end_byte..].to_string())); + } + Line::from(spans) + } else { + // Middle line - fully selected + let styled_spans: Vec = line.spans.into_iter().map(|span| { + Span::styled( + span.content, + span.style.bg(Color::LightBlue).fg(Color::Black) + ) + }).collect(); + Line::from(styled_spans) + } + }).collect() + } else { + lines + } +} + +fn char_to_byte_index(s: &str, char_idx: usize) -> usize { + if char_idx == 0 { + return 0; + } + + let mut iter = s.char_indices(); + for (i, (byte_idx, _)) in iter.by_ref().enumerate() { + if i == char_idx { + return byte_idx; + } + } + s.len() +} + fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Calculate viewport dimensions for autoscroll calculations let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders @@ -556,6 +663,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { lines.push(Line::from("No messages yet. Press 'i' to start typing.")); } + // Apply visual selection highlighting if in visual mode and Chat panel is focused + if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat) { + if let Some(selection) = app.visual_selection() { + lines = apply_visual_selection(lines, Some(selection)); + } + } + // Update AutoScroll state with accurate content length let auto_scroll = app.auto_scroll_mut(); auto_scroll.content_len = lines.len(); @@ -563,15 +677,47 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let scroll_position = app.scroll().min(u16::MAX as usize) as u16; + // Highlight border if this panel is focused + let border_color = if matches!(app.focused_panel(), FocusedPanel::Chat) { + Color::LightMagenta + } else { + Color::Rgb(95, 20, 135) + }; + let paragraph = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), + .border_style(Style::default().fg(border_color)), ) .scroll((scroll_position, 0)); frame.render_widget(paragraph, area); + + // Render cursor if Chat panel is focused and in Normal mode + if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal) { + let cursor = app.chat_cursor(); + let cursor_row = cursor.0; + let cursor_col = cursor.1; + + // Calculate visible cursor position (accounting for scroll) + if cursor_row >= scroll_position as usize && cursor_row < (scroll_position as usize + viewport_height) { + let visible_row = cursor_row - scroll_position as usize; + let cursor_y = area.y + 1 + visible_row as u16; // +1 for border + + // Get the rendered line and calculate display width + let rendered_lines = app.get_rendered_lines(); + if let Some(line_text) = rendered_lines.get(cursor_row) { + let chars: Vec = line_text.chars().collect(); + let text_before_cursor: String = chars.iter().take(cursor_col).collect(); + let display_width = UnicodeWidthStr::width(text_before_cursor.as_str()); + + let cursor_x = area.x + 1 + display_width as u16; // +1 for border only + + frame.set_cursor_position((cursor_x, cursor_y)); + } + } + } } fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { @@ -583,7 +729,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let chunks = wrap(&thinking, content_width as usize); - let lines: Vec = chunks + let mut lines: Vec = chunks .into_iter() .map(|seg| { Line::from(Span::styled( @@ -595,6 +741,13 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { }) .collect(); + // Apply visual selection highlighting if in visual mode and Thinking panel is focused + if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Thinking) { + if let Some(selection) = app.visual_selection() { + lines = apply_visual_selection(lines, Some(selection)); + } + } + // Update AutoScroll state with accurate content length let thinking_scroll = app.thinking_scroll_mut(); thinking_scroll.content_len = lines.len(); @@ -602,6 +755,13 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16; + // Highlight border if this panel is focused + let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) { + Color::LightMagenta + } else { + Color::DarkGray + }; + let paragraph = Paragraph::new(lines) .block( Block::default() @@ -612,21 +772,53 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .add_modifier(Modifier::ITALIC), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(border_color)), ) .scroll((scroll_position, 0)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); + + // Render cursor if Thinking panel is focused and in Normal mode + if matches!(app.focused_panel(), FocusedPanel::Thinking) && matches!(app.mode(), InputMode::Normal) { + let cursor = app.thinking_cursor(); + let cursor_row = cursor.0; + let cursor_col = cursor.1; + + // Calculate visible cursor position (accounting for scroll) + if cursor_row >= scroll_position as usize && cursor_row < (scroll_position as usize + viewport_height) { + let visible_row = cursor_row - scroll_position as usize; + let cursor_y = area.y + 1 + visible_row as u16; // +1 for border + + // Calculate actual display width by measuring characters up to cursor + let line_text = thinking.lines().nth(cursor_row).unwrap_or(""); + let chars: Vec = line_text.chars().collect(); + let text_before_cursor: String = chars.iter().take(cursor_col).collect(); + let display_width = UnicodeWidthStr::width(text_before_cursor.as_str()); + + let cursor_x = area.x + 1 + display_width as u16; // +1 for border only + + frame.set_cursor_position((cursor_x, cursor_y)); + } + } } } -fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { +fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let title = match app.mode() { InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ", + InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ", + InputMode::Command => " Command Mode (Enter=execute · Esc=cancel) ", _ => " Input (Press 'i' to start typing) ", }; + // Highlight border if this panel is focused + let border_color = if matches!(app.focused_panel(), FocusedPanel::Input) { + Color::LightMagenta + } else { + Color::Rgb(95, 20, 135) + }; + let input_block = Block::default() .title(Span::styled( title, @@ -635,13 +827,35 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))); + .border_style(Style::default().fg(border_color)); if matches!(app.mode(), InputMode::Editing) { - let mut textarea = app.textarea().clone(); + // Use the textarea directly to preserve selection state + let textarea = app.textarea_mut(); textarea.set_block(input_block.clone()); textarea.set_hard_tab_indent(false); - render_editable_textarea(frame, area, &mut textarea, true); + render_editable_textarea(frame, area, textarea, true); + } else if matches!(app.mode(), InputMode::Visual) { + // In visual mode, render textarea in read-only mode with selection + let textarea = app.textarea_mut(); + textarea.set_block(input_block.clone()); + textarea.set_hard_tab_indent(false); + render_editable_textarea(frame, area, textarea, true); + } else if matches!(app.mode(), InputMode::Command) { + // In command mode, show the command buffer with : prefix + let command_text = format!(":{}", app.command_buffer()); + let lines = vec![Line::from(Span::styled( + command_text, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))]; + + let paragraph = Paragraph::new(lines) + .block(input_block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); } else { // In non-editing mode, show the current input buffer content as read-only let input_text = app.input_buffer().text(); @@ -700,6 +914,8 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { InputMode::ModelSelection => (" MODEL", Color::LightYellow), InputMode::ProviderSelection => (" PROVIDER", Color::LightCyan), InputMode::Help => (" HELP", Color::LightMagenta), + InputMode::Visual => (" VISUAL", Color::Magenta), + InputMode::Command => (" COMMAND", Color::Yellow), }; let status_message = if app.streaming_count() > 0 { @@ -710,7 +926,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { "Ready".to_string() }; - let help_text = "i:Input m:Model c:Clear q:Quit"; + let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit"; let left_spans = vec![ Span::styled( @@ -845,18 +1061,74 @@ fn render_help(frame: &mut Frame<'_>) { frame.render_widget(Clear, area); let help_text = vec![ - Line::from("Controls:"), - Line::from(" i / Enter → start typing"), - Line::from(" Enter → send message"), - Line::from(" Ctrl+J → newline"), - Line::from(" j / ↓ → scroll down"), - Line::from(" k / ↑ → scroll up"), - Line::from(" m → select model"), - Line::from(" n → new conversation"), - Line::from(" c → clear conversation"), - Line::from(" q → quit"), + Line::from("MODES:"), + Line::from(" Normal → default mode for navigation"), + Line::from(" Insert → editing input text"), + Line::from(" Visual → selecting text"), + Line::from(" Command → executing commands (: prefix)"), Line::from(""), - Line::from("Press Esc to close this help."), + Line::from("PANEL NAVIGATION:"), + Line::from(" Tab → cycle panels forward"), + Line::from(" Shift+Tab → cycle panels backward"), + Line::from(" (Panels: Chat, Thinking, Input)"), + Line::from(""), + Line::from("CURSOR MOVEMENT (Normal mode, Chat/Thinking panels):"), + Line::from(" h/← l/→ → move left/right by character"), + Line::from(" j/↓ k/↑ → move down/up by line"), + Line::from(" w → forward to next word start"), + Line::from(" e → forward to word end"), + Line::from(" b → backward to previous word"), + Line::from(" 0 / Home → start of line"), + Line::from(" ^ → first non-blank character"), + Line::from(" $ / End → end of line"), + Line::from(" gg → jump to top"), + Line::from(" G → jump to bottom"), + Line::from(" Ctrl+d/u → scroll half page down/up"), + Line::from(" Ctrl+f/b → scroll full page down/up"), + Line::from(" PageUp/Down → scroll full page"), + Line::from(""), + Line::from("EDITING (Normal mode):"), + Line::from(" i / Enter → enter insert mode at cursor"), + Line::from(" a → append after cursor"), + Line::from(" A → append at end of line"), + Line::from(" I → insert at start of line"), + Line::from(" o → insert line below and enter insert mode"), + Line::from(" O → insert line above and enter insert mode"), + Line::from(" dd → clear input buffer"), + Line::from(" p → paste from clipboard to input"), + Line::from(" Esc → return to normal mode"), + Line::from(""), + Line::from("INSERT MODE:"), + Line::from(" Enter → send message"), + Line::from(" Ctrl+J → insert newline"), + Line::from(" Ctrl+↑/↓ → navigate input history"), + Line::from(" Ctrl+A → start of line"), + Line::from(" Ctrl+E → end of line"), + Line::from(" Ctrl+W → word forward"), + Line::from(" Ctrl+B → word backward"), + Line::from(" Esc → return to normal mode"), + Line::from(""), + Line::from("VISUAL MODE (all panels):"), + Line::from(" v → enter visual mode at cursor"), + Line::from(" h/j/k/l → extend selection left/down/up/right"), + Line::from(" w / e / b → extend by word (start/end/back)"), + Line::from(" 0 / ^ / $ → extend to line start/first char/end"), + Line::from(" y → yank (copy) selection"), + Line::from(" d → yank selection (delete in Input)"), + Line::from(" v / Esc → exit visual mode"), + Line::from(""), + Line::from("COMMANDS (press : then type):"), + Line::from(" :h, :help → show this help"), + Line::from(" :m, :model → select model"), + Line::from(" :n, :new → start new conversation"), + Line::from(" :c, :clear → clear current conversation"), + Line::from(" :q, :quit → quit application"), + Line::from(""), + Line::from("QUICK KEYS:"), + Line::from(" q → quit (from normal mode)"), + Line::from(" Ctrl+C → quit"), + Line::from(""), + Line::from("Press Esc or Enter to close this help."), ]; let paragraph = Paragraph::new(help_text).block(