diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 4d0e1a3..1f12635 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture, DisableBracketedPaste, EnableBracketedPaste}, + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -57,7 +57,12 @@ async fn main() -> Result<()> { // Terminal setup enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; diff --git a/crates/owlen-core/src/formatting.rs b/crates/owlen-core/src/formatting.rs index 6d4a735..39d211a 100644 --- a/crates/owlen-core/src/formatting.rs +++ b/crates/owlen-core/src/formatting.rs @@ -35,7 +35,12 @@ impl MessageFormatter { } pub fn format_message(&self, message: &Message) -> Vec { - message.content.trim().lines().map(|s| s.to_string()).collect() + message + .content + .trim() + .lines() + .map(|s| s.to_string()) + .collect() } /// Extract thinking content from tags, returning (content_without_think, thinking_content) diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index 7ad9942..3855db7 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -144,7 +144,8 @@ impl SessionController { self.conversation.push_user_message(content); - self.send_request_with_current_conversation(parameters).await + self.send_request_with_current_conversation(parameters) + .await } /// Send a request using the current conversation without adding a new user message diff --git a/crates/owlen-core/src/ui.rs b/crates/owlen-core/src/ui.rs index 4d3c579..313ca0b 100644 --- a/crates/owlen-core/src/ui.rs +++ b/crates/owlen-core/src/ui.rs @@ -422,4 +422,4 @@ mod tests { assert_eq!(find_prev_word_boundary(line, 11), Some(6)); assert_eq!(find_prev_word_boundary(line, 6), Some(0)); } -} \ No newline at end of file +} diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index c92a106..18707d4 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -51,10 +51,10 @@ pub struct ChatApp { 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) + 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 { @@ -364,9 +364,7 @@ impl ChatApp { self.sync_buffer_to_textarea(); // Set a visible selection style self.textarea.set_selection_style( - Style::default() - .bg(Color::LightBlue) - .fg(Color::Black) + Style::default().bg(Color::LightBlue).fg(Color::Black), ); // Start visual selection at current cursor position self.textarea.start_selection(); @@ -374,7 +372,8 @@ impl ChatApp { } FocusedPanel::Chat | FocusedPanel::Thinking => { // For scrollable panels, start selection at cursor position - let cursor = if matches!(self.focused_panel, FocusedPanel::Chat) { + let cursor = if matches!(self.focused_panel, FocusedPanel::Chat) + { self.chat_cursor } else { self.thinking_cursor @@ -463,7 +462,8 @@ impl ChatApp { 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; + let viewport_bottom = + self.auto_scroll.scroll + self.viewport_height; if self.chat_cursor.0 >= viewport_bottom { self.on_scroll(1); } @@ -473,7 +473,8 @@ impl ChatApp { 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; + let viewport_bottom = self.thinking_scroll.scroll + + self.thinking_viewport_height; if self.thinking_cursor.0 >= viewport_bottom { self.on_scroll(1); } @@ -486,133 +487,135 @@ impl ChatApp { } // 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; - } + | (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; - } - } - _ => {} } - } + 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; - } + | (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; - } - } - } - _ => {} } - } + 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; - } + (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_next_word_boundary( + self.thinking_cursor.0, + self.thinking_cursor.1, + ) { + self.thinking_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; - } + _ => {} + }, + (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_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(new_col) = self + .find_word_end(self.thinking_cursor.0, self.thinking_cursor.1) + { + self.thinking_cursor.1 = new_col; } - 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; - } - } - _ => {} } - } + _ => {} + }, + (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('0'), KeyModifiers::NONE) + | (KeyCode::Home, KeyModifiers::NONE) => match self.focused_panel { + FocusedPanel::Chat => { + self.chat_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(); - } - } - _ => {} + 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(); @@ -647,7 +650,8 @@ impl ChatApp { 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(); + 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; @@ -692,7 +696,7 @@ impl ChatApp { self.pending_key = None; } } - }, + } InputMode::Editing => match (key.code, key.modifiers) { (KeyCode::Esc, KeyModifiers::NONE) => { // Sync textarea content to input buffer before leaving edit mode @@ -733,10 +737,12 @@ impl ChatApp { 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); + 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); + self.textarea + .move_cursor(tui_textarea::CursorMove::WordBack); } (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { // Redo - history next @@ -774,7 +780,8 @@ impl ChatApp { 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.status = + format!("Yanked line ({} chars)", self.clipboard.len()); } } self.textarea.cancel_selection(); @@ -812,7 +819,10 @@ impl ChatApp { // 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()); + self.status = format!( + "Yanked {} chars (read-only panel)", + self.clipboard.len() + ); } else { self.status = "Nothing to yank".to_string(); } @@ -877,11 +887,12 @@ impl ChatApp { // 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 - }; + 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 @@ -894,7 +905,8 @@ impl ChatApp { (KeyCode::Char('w'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::WordForward); + self.textarea + .move_cursor(tui_textarea::CursorMove::WordForward); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection forward by word @@ -909,7 +921,8 @@ impl ChatApp { (KeyCode::Char('b'), KeyModifiers::NONE) => { match self.focused_panel { FocusedPanel::Input => { - self.textarea.move_cursor(tui_textarea::CursorMove::WordBack); + self.textarea + .move_cursor(tui_textarea::CursorMove::WordBack); } FocusedPanel::Chat | FocusedPanel::Thinking => { // Move selection backward by word @@ -997,7 +1010,8 @@ impl ChatApp { self.command_buffer.clear(); self.mode = InputMode::Normal; } - (KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => { + (KeyCode::Char(c), KeyModifiers::NONE) + | (KeyCode::Char(c), KeyModifiers::SHIFT) => { self.command_buffer.push(c); self.status = format!(":{}", self.command_buffer); } @@ -1282,7 +1296,9 @@ impl ChatApp { // Step 1: Add user message to conversation immediately (synchronous) let message = self.controller.input_buffer_mut().commit_to_history(); - self.controller.conversation_mut().push_user_message(message.clone()); + self.controller + .conversation_mut() + .push_user_message(message.clone()); // Auto-scroll to bottom when sending a message self.auto_scroll.stick_to_bottom = true; @@ -1308,7 +1324,11 @@ impl ChatApp { parameters.stream = self.controller.config().general.enable_streaming; // Step 2: Start the actual request - match self.controller.send_request_with_current_conversation(parameters).await { + match self + .controller + .send_request_with_current_conversation(parameters) + .await + { Ok(SessionOutcome::Complete(_response)) => { self.stop_loading_animation(); self.status = "Ready".to_string(); @@ -1323,10 +1343,7 @@ impl ChatApp { self.status = "Generating response...".to_string(); self.spawn_stream(response_id, stream); - match self - .controller - .mark_stream_placeholder(response_id, "▌") - { + match self.controller.mark_stream_placeholder(response_id, "▌") { Ok(_) => self.error = None, Err(err) => { self.error = Some(format!("Could not set response placeholder: {}", err)); @@ -1354,7 +1371,6 @@ impl ChatApp { } } - fn sync_selected_model_index(&mut self) { let current_model_id = self.controller.selected_model().to_string(); let filtered_models: Vec<&ModelInfo> = self @@ -1409,7 +1425,8 @@ impl ChatApp { pub fn advance_loading_animation(&mut self) { if self.is_loading { - self.loading_animation_frame = (self.loading_animation_frame + 1) % 8; // 8-frame animation + self.loading_animation_frame = (self.loading_animation_frame + 1) % 8; + // 8-frame animation } } @@ -1452,7 +1469,8 @@ impl ChatApp { }; let content_to_display = if matches!(role, Role::Assistant) { - let (content_without_think, _) = formatter.extract_thinking(&message.content); + let (content_without_think, _) = + formatter.extract_thinking(&message.content); content_without_think } else { message.content.clone() @@ -1505,7 +1523,8 @@ impl ChatApp { } fn yank_from_panel(&self) -> Option { - let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end) { + 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) @@ -1522,7 +1541,13 @@ impl ChatApp { 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)) { + 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; @@ -1540,8 +1565,6 @@ impl ChatApp { } } - - fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) { let sender = self.session_tx.clone(); self.streaming.insert(message_id); diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index f08bc48..8b73442 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -8,8 +8,8 @@ use tui_textarea::TextArea; use unicode_width::UnicodeWidthStr; use crate::chat_app::ChatApp; -use owlen_core::ui::{FocusedPanel, InputMode}; use owlen_core::types::Role; +use owlen_core::ui::{FocusedPanel, InputMode}; pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Update thinking content from last message @@ -37,18 +37,15 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Calculate thinking section height let thinking_height = if let Some(thinking) = app.current_thinking() { let content_width = available_width.saturating_sub(4); - let visual_lines = calculate_wrapped_line_count( - thinking.lines(), - content_width, - ); + let visual_lines = calculate_wrapped_line_count(thinking.lines(), content_width); (visual_lines as u16).min(6) + 2 // +2 for borders, max 6 lines } else { 0 }; let mut constraints = vec![ - Constraint::Length(4), // Header - Constraint::Min(8), // Messages + Constraint::Length(4), // Header + Constraint::Min(8), // Messages ]; if thinking_height > 0 { @@ -56,7 +53,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { } constraints.push(Constraint::Length(input_height)); // Input - constraints.push(Constraint::Length(3)); // Status + constraints.push(Constraint::Length(3)); // Status let layout = Layout::default() .direction(Direction::Vertical) @@ -437,89 +434,100 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { frame.render_widget(paragraph, inner_area); } - -fn apply_visual_selection(lines: Vec, selection: Option<((usize, usize), (usize, usize))>) -> Vec { +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)) - }; + 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 { + lines + .into_iter() + .enumerate() + .map(|(idx, line)| { + if idx < start_r || idx > end_r { + // Line not in selection return line; } - let start_byte = char_to_byte_index(&line_text, sel_start); - let end_byte = char_to_byte_index(&line_text, sel_end); + // Convert line to plain text for character indexing + let line_text = line.to_string(); + let char_count = line_text.chars().count(); - 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); + 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); - 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); + if sel_start >= sel_end { + return line; + } - 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())); + 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) } - 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() + }) + .collect() } else { lines } @@ -569,7 +577,11 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { message.content.clone() }; - let formatted: Vec = content_to_display.trim().lines().map(|s| s.to_string()).collect(); + let formatted: Vec = content_to_display + .trim() + .lines() + .map(|s| s.to_string()) + .collect(); let is_streaming = message .metadata .get("streaming") @@ -586,10 +598,11 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { ]; // Add loading indicator if applicable - if matches!(role, Role::Assistant) && - app.get_loading_indicator() != "" && - message_index == conversation.messages.len() - 1 && - is_streaming { + if matches!(role, Role::Assistant) + && app.get_loading_indicator() != "" + && message_index == conversation.messages.len() - 1 + && is_streaming + { role_line_spans.push(Span::styled( format!(" {}", app.get_loading_indicator()), Style::default().fg(Color::Yellow), @@ -640,7 +653,9 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Add loading indicator ONLY if we're loading and there are no messages at all, // or if the last message is from the user (no Assistant response started yet) - let last_message_is_user = conversation.messages.last() + let last_message_is_user = conversation + .messages + .last() .map(|msg| matches!(msg.role, Role::User)) .unwrap_or(true); @@ -649,7 +664,9 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { Span::raw("🤖 "), Span::styled( "Assistant:", - Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::LightMagenta) + .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {}", app.get_loading_indicator()), @@ -664,7 +681,8 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } // 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 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)); } @@ -695,13 +713,16 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { 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) { + 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) { + 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 @@ -742,7 +763,9 @@ 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 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)); } @@ -780,13 +803,17 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { 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) { + 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) { + 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