diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c44c5d..3f8cc9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys). - Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`. - Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in. +- Chat history honors `ui.scrollback_lines`, trimming older rows to keep the TUI responsive and surfacing a "↓ New messages" badge whenever updates land off-screen. ### Changed - The main `README.md` has been updated to be more concise and link to the new documentation. diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index cdf2e21..4e55d63 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -708,6 +708,8 @@ pub struct UiSettings { pub show_onboarding: bool, #[serde(default = "UiSettings::default_input_max_rows")] pub input_max_rows: u16, + #[serde(default = "UiSettings::default_scrollback_lines")] + pub scrollback_lines: usize, } impl UiSettings { @@ -738,6 +740,10 @@ impl UiSettings { const fn default_input_max_rows() -> u16 { 5 } + + const fn default_scrollback_lines() -> usize { + 2000 + } } impl Default for UiSettings { @@ -750,6 +756,7 @@ impl Default for UiSettings { wrap_column: Self::default_wrap_column(), show_onboarding: Self::default_show_onboarding(), input_max_rows: Self::default_input_max_rows(), + scrollback_lines: Self::default_scrollback_lines(), } } } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index fd186db..c1aa6ec 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -173,6 +173,7 @@ pub struct ChatApp { 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) + chat_line_offset: usize, // Number of leading lines trimmed for scrollback thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) code_view_path: Option, // Active code view file path code_view_lines: Vec, // Cached lines for code view rendering @@ -194,6 +195,8 @@ pub struct ChatApp { agent_running: bool, /// Operating mode (Chat or Code) operating_mode: owlen_core::mode::Mode, + /// Flag indicating new messages arrived while scrolled away from tail + new_message_alert: bool, } #[derive(Clone, Debug)] @@ -266,6 +269,7 @@ impl ChatApp { visual_end: None, focused_panel: FocusedPanel::Input, chat_cursor: (0, 0), + chat_line_offset: 0, thinking_cursor: (0, 0), code_view_path: None, code_view_lines: Vec::new(), @@ -287,6 +291,7 @@ impl ChatApp { agent_mode: false, agent_running: false, operating_mode: owlen_core::mode::Mode::default(), + new_message_alert: false, }; if show_onboarding { @@ -684,6 +689,80 @@ impl ChatApp { config.ui.input_max_rows.max(1) } + pub fn scrollback_limit(&self) -> usize { + let limit = { + let config = self.controller.config(); + config.ui.scrollback_lines + }; + if limit == 0 { usize::MAX } else { limit } + } + + pub fn has_new_message_alert(&self) -> bool { + self.new_message_alert + } + + pub fn clear_new_message_alert(&mut self) { + self.new_message_alert = false; + } + + fn notify_new_activity(&mut self) { + if !self.auto_scroll.stick_to_bottom { + self.new_message_alert = true; + } + } + + fn update_new_message_alert_after_scroll(&mut self) { + if self.auto_scroll.stick_to_bottom { + self.clear_new_message_alert(); + } + } + + pub fn apply_chat_scrollback_trim(&mut self, removed: usize, remaining: usize) { + if removed == 0 { + self.chat_line_offset = 0; + self.chat_cursor.0 = self.chat_cursor.0.min(remaining.saturating_sub(1)); + return; + } + + self.chat_line_offset = removed; + self.auto_scroll.scroll = self.auto_scroll.scroll.saturating_sub(removed); + self.auto_scroll.content_len = remaining; + + if let Some((row, _)) = &mut self.visual_start { + if *row < removed { + self.visual_start = None; + } else { + *row -= removed; + } + } + + if let Some((row, _)) = &mut self.visual_end { + if *row < removed { + self.visual_end = None; + } else { + *row -= removed; + } + } + + self.chat_cursor.0 = self.chat_cursor.0.saturating_sub(removed); + if remaining == 0 { + self.chat_cursor = (0, 0); + } else if self.chat_cursor.0 >= remaining { + self.chat_cursor.0 = remaining - 1; + } + + let max_scroll = remaining.saturating_sub(self.viewport_height); + if self.auto_scroll.scroll > max_scroll { + self.auto_scroll.scroll = max_scroll; + } + + if self.auto_scroll.stick_to_bottom { + self.auto_scroll.on_viewport(self.viewport_height); + } + + self.update_new_message_alert_after_scroll(); + } + pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; } @@ -1321,8 +1400,7 @@ impl ChatApp { FocusedPanel::Code => {} _ => {} }, - (KeyCode::Char('$'), KeyModifiers::NONE) - | (KeyCode::End, KeyModifiers::NONE) => match self.focused_panel { + (KeyCode::Char('$'), 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(); @@ -1337,6 +1415,22 @@ impl ChatApp { FocusedPanel::Code => {} _ => {} }, + (KeyCode::End, KeyModifiers::NONE) => match self.focused_panel { + FocusedPanel::Chat => { + self.jump_to_bottom(); + } + FocusedPanel::Thinking => { + let viewport_height = self.thinking_viewport_height.max(1); + self.thinking_scroll.jump_to_bottom(viewport_height); + } + FocusedPanel::Code => { + if self.code_view_path.is_some() { + let viewport = self.code_view_viewport_height.max(1); + self.code_view_scroll.jump_to_bottom(viewport); + } + } + FocusedPanel::Input => {} + }, // Half-page scrolling (KeyCode::Char('d'), KeyModifiers::CONTROL) => { self.scroll_half_page_down(); @@ -1752,6 +1846,9 @@ impl ChatApp { } "c" | "clear" => { self.controller.clear(); + self.chat_line_offset = 0; + self.auto_scroll = AutoScroll::default(); + self.clear_new_message_alert(); self.status = "Conversation cleared".to_string(); } "w" | "write" | "save" => { @@ -2603,6 +2700,7 @@ impl ChatApp { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.on_user_scroll(delta, self.viewport_height); + self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { // Ensure we have a valid viewport height @@ -2626,6 +2724,7 @@ impl ChatApp { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_half_page_down(self.viewport_height); + self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); @@ -2646,6 +2745,7 @@ impl ChatApp { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_half_page_up(self.viewport_height); + self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); @@ -2666,6 +2766,7 @@ impl ChatApp { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_full_page_down(self.viewport_height); + self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); @@ -2686,6 +2787,7 @@ impl ChatApp { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.scroll_full_page_up(self.viewport_height); + self.update_new_message_alert_after_scroll(); } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); @@ -2706,6 +2808,7 @@ impl ChatApp { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.jump_to_top(); + self.chat_cursor = (0, 0); } FocusedPanel::Thinking => { self.thinking_scroll.jump_to_top(); @@ -2724,6 +2827,18 @@ impl ChatApp { match self.focused_panel { FocusedPanel::Chat => { self.auto_scroll.jump_to_bottom(self.viewport_height); + self.update_new_message_alert_after_scroll(); + let rendered = self.get_rendered_lines(); + if rendered.is_empty() { + self.chat_cursor = (0, 0); + } else { + let last_index = rendered.len().saturating_sub(1); + let last_col = rendered + .last() + .map(|line| line.chars().count()) + .unwrap_or(0); + self.chat_cursor = (last_index, last_col); + } } FocusedPanel::Thinking => { let viewport_height = self.thinking_viewport_height.max(1); @@ -2749,6 +2864,7 @@ impl ChatApp { // Update thinking content in real-time during streaming self.update_thinking_from_last_message(); + self.notify_new_activity(); // Auto-scroll will handle this in the render loop if response.is_final { @@ -2815,6 +2931,7 @@ impl ChatApp { self.controller .conversation_mut() .push_assistant_message(answer); + self.notify_new_activity(); self.agent_running = false; self.agent_mode = false; self.agent_actions = None; @@ -3889,6 +4006,11 @@ impl ChatApp { lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator())); } + if self.chat_line_offset > 0 { + let skip = self.chat_line_offset.min(lines.len()); + lines = lines.into_iter().skip(skip).collect(); + } + if lines.is_empty() { lines.push("No messages yet. Press 'i' to start typing.".to_string()); } diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 33cb306..7c0f854 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -826,6 +826,15 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { lines.push(Line::from("No messages yet. Press 'i' to start typing.")); } + let scrollback_limit = app.scrollback_limit(); + if scrollback_limit != usize::MAX && lines.len() > scrollback_limit { + let removed = lines.len() - scrollback_limit; + lines = lines.into_iter().skip(removed).collect(); + app.apply_chat_scrollback_trim(removed, lines.len()); + } else { + app.apply_chat_scrollback_trim(0, lines.len()); + } + // 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) @@ -860,6 +869,31 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { frame.render_widget(paragraph, area); + if app.has_new_message_alert() { + let badge_text = "↓ New messages (press G)"; + let text_width = badge_text.chars().count() as u16; + let badge_width = text_width.saturating_add(2); + if area.width > badge_width + 1 && area.height > 2 { + let badge_x = area.x + area.width.saturating_sub(badge_width + 1); + let badge_y = area.y + 1; + let badge_area = Rect::new(badge_x, badge_y, badge_width, 1); + frame.render_widget(Clear, badge_area); + let badge_line = Line::from(Span::styled( + format!(" {badge_text} "), + Style::default() + .fg(theme.background) + .bg(theme.info) + .add_modifier(Modifier::BOLD), + )); + frame.render_widget( + Paragraph::new(badge_line) + .style(Style::default().bg(theme.info).fg(theme.background)) + .alignment(Alignment::Center), + badge_area, + ); + } + } + // Render cursor if Chat panel is focused and in Normal mode if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal) { diff --git a/docs/configuration.md b/docs/configuration.md index 5f4e223..adf46c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,9 @@ These settings customize the look and feel of the terminal interface. - `input_max_rows` (integer, default: `5`) The maximum number of rows the input panel will expand to before it starts scrolling internally. Increase this value if you prefer to see more of long prompts while editing. +- `scrollback_lines` (integer, default: `2000`) + The maximum number of rendered lines the chat view keeps in memory. Set to `0` to disable trimming entirely if you prefer unlimited history. + ## Storage Settings (`[storage]`) These settings control how conversations are saved and loaded. diff --git a/docs/migrations/README.md b/docs/migrations/README.md index c088385..b1a89f0 100644 --- a/docs/migrations/README.md +++ b/docs/migrations/README.md @@ -4,7 +4,7 @@ Owlen is still in alpha, so configuration and storage formats may change between ### Schema 1.2.0 (November 2025) -`config.toml` now records `schema_version = "1.2.0"` and introduces the optional `ui.input_max_rows` setting. The new key defaults to `5`, so no manual edits are required unless you prefer a taller input panel. Existing files are updated automatically on load/save. +`config.toml` now records `schema_version = "1.2.0"` and introduces the optional `ui.input_max_rows` and `ui.scrollback_lines` settings. The new keys default to `5` and `2000` respectively, so no manual edits are required unless you want a taller input panel or a different scrollback cap. Existing files are updated automatically on load/save. ### Schema 1.1.0 (October 2025)