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

@@ -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.

View File

@@ -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(),
}
}
}

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());
}

View File

@@ -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)
{

View File

@@ -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.

View File

@@ -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)