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:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user