feat(ui): add configurable scrollback lines and new‑message alert badge

Introduce `ui.scrollback_lines` (default 2000) to cap the number of chat lines kept in memory, with `0` disabling trimming. Implement automatic trimming of older lines, maintain a scroll offset, and show a “↓ New messages (press G)” badge when new messages arrive off‑screen. Update core UI settings, TUI rendering, chat app state, migrations, documentation, and changelog to reflect the new feature.
This commit is contained in:
2025-10-12 14:23:04 +02:00
parent 82078afd6d
commit 60c859b3ab
6 changed files with 170 additions and 3 deletions

View File

@@ -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<String>, // Active code view file path
code_view_lines: Vec<String>, // 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());
}