diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 4ed1138..e5caf2a 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -64,6 +64,8 @@ pub struct ChatApp { pub selected_provider_index: usize, // Index into the available_providers list pub selected_model: Option, // Index into the *filtered* models list scroll: usize, + viewport_height: usize, // Track the height of the messages viewport + content_width: usize, // Track the content width for line wrapping calculations session_tx: mpsc::UnboundedSender, streaming: HashSet, textarea: TextArea<'static>, // Advanced text input widget @@ -86,6 +88,8 @@ impl ChatApp { selected_provider_index: 0, selected_model: None, scroll: 0, + viewport_height: 10, // Default viewport height, will be updated during rendering + content_width: 80, // Default content width, will be updated during rendering session_tx, streaming: std::collections::HashSet::new(), textarea, @@ -396,9 +400,9 @@ impl ChatApp { message_id, response, } => { - let at_bottom = self.scroll == 0; + let was_at_bottom = self.is_at_bottom(); self.controller.apply_stream_chunk(message_id, &response)?; - if at_bottom { + if was_at_bottom { self.scroll_to_bottom(); } if response.is_final { @@ -559,8 +563,52 @@ impl ChatApp { } } + pub fn set_viewport_dimensions(&mut self, height: usize, content_width: usize) { + self.viewport_height = height; + self.content_width = content_width; + } + + fn is_at_bottom(&self) -> bool { + let total_lines = self.calculate_total_content_lines(); + let max_scroll = total_lines.saturating_sub(self.viewport_height); + + // We're at bottom if scroll is at or near the maximum scroll position + self.scroll >= max_scroll || total_lines <= self.viewport_height + } + + fn calculate_total_content_lines(&self) -> usize { + let conversation = self.controller.conversation(); + let mut formatter = self.controller.formatter().clone(); + + // Set the wrap width to match the current display + formatter.set_wrap_width(self.content_width); + + let mut total_lines = 0; + for (message_index, message) in conversation.messages.iter().enumerate() { + let formatted = formatter.format_message(message); + let show_role_labels = formatter.show_role_labels(); + + // Add role label line if enabled + if show_role_labels { + total_lines += 1; + } + + // Add message content lines + total_lines += formatted.len(); + + // Add empty line after each message, except the last one + if message_index < conversation.messages.len() - 1 { + total_lines += 1; + } + } + + // Minimum 1 line for empty state + total_lines.max(1) + } + fn scroll_to_bottom(&mut self) { - self.scroll = 0; + let total_lines = self.calculate_total_content_lines(); + self.scroll = total_lines.saturating_sub(self.viewport_height); } fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) { diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 6603bf0..94a139b 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -10,7 +10,7 @@ use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, InputMode}; use owlen_core::types::Role; -pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) { +pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Calculate dynamic input height based on textarea content let available_width = frame.area().width; let input_height = if matches!(app.mode(), InputMode::Editing) { @@ -345,12 +345,16 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { frame.render_widget(paragraph, inner_area); } -fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { +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 + let content_width = area.width.saturating_sub(4).max(20); + app.set_viewport_dimensions(viewport_height, usize::from(content_width)); + let conversation = app.conversation(); let mut formatter = app.formatter().clone(); // Reserve space for borders and the message indent so text fits within the block - let content_width = area.width.saturating_sub(4).max(20); formatter.set_wrap_width(usize::from(content_width)); let mut lines: Vec = Vec::new();