@setoelkahfi / sigit / commits / ac31fb0

fix(tui): keep latest message visible in long chats

The message view pinned itself to the bottom using a hand-rolled line-count estimate (`total_message_lines`/`wrapped_line_count`) that diverged from what was actually rendered. Most notably it ignored the `<think>` reasoning box lines and only approximated word wrapping, so it undercounted the real wrapped-row total. `auto_scroll` then set a scroll offset that was too small and the bottom of the conversation — including the latest reply — scrolled off-screen. Compute the scroll offset from `Paragraph::line_count`, which runs the same WordWrapper as rendering, so the count never diverges from what is drawn. There is no scrollback, so we always pin to the bottom. The estimate helpers and the now-unused `scroll_offset` field are removed. Enables ratatui's `unstable-rendered-line-info` feature for `Paragraph::line_count`. Fixes #6 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PLYyUFtjALXe2KvWgouMYg

Claude committed Jun 25, 2026 at 09:14 UTC ac31fb00d2ec92ad6a8672a09bde282c3e2d2e22
2 files changed +16 -71
Cargo.toml
+4 -1
index 42b2d69..3126e50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,10 @@ tokio-util = { version = "0.7", features = ["compat"] } futures = "0.3" # TUI (interactive chat mode) -ratatui = "0.29" +# `unstable-rendered-line-info` exposes `Paragraph::line_count`, which we use to +# pin the message view to the bottom using the exact wrapped-row count (the same +# WordWrapper that rendering uses) rather than a hand-rolled estimate. +ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] } crossterm = { version = "0.29", features = ["event-stream"] } # Utilities
src/chat.rs
+12 -70
index df35406..927212f 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -163,7 +163,6 @@ mod tui { messages: Vec<ChatMessage>, input: String, cursor: usize, - scroll_offset: u16, stream_rx: Option<mpsc::Receiver<StreamChunk>>, stream_buf: String, inference_rx: Option<mpsc::Receiver<InferenceUpdate>>, @@ -259,7 +258,6 @@ mod tui { messages: Vec::new(), input: String::new(), cursor: 0, - scroll_offset: 0, stream_rx: None, stream_buf: String::new(), inference_rx: None, @@ -433,57 +431,6 @@ mod tui { } self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len(); } - - /// rough line count for scroll math - fn total_message_lines(&self, width: u16) -> u16 { - if width == 0 { - return 0; - } - let w = width.saturating_sub(2) as usize; - let mut lines: u16 = 0; - for msg in &self.messages { - lines += wrapped_line_count(&msg.text, msg.role, w); - } - if !self.stream_buf.is_empty() { - lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); - } - if self.thinking || self.switching_model { - lines += 1; - } - lines - } - - fn auto_scroll(&mut self, visible_height: u16, width: u16) { - let total = self.total_message_lines(width); - if total > visible_height { - self.scroll_offset = total - visible_height; - } else { - self.scroll_offset = 0; - } - } - } - - fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { - let prefix_len = match role { - Role::User => 6, // "you > " - Role::Assistant => 8, // "siGit > " - Role::System | Role::Banner => 0, - }; - let effective = if width > prefix_len { - width - prefix_len - } else { - 1 - }; - - let mut count: u16 = 0; - for line in text.split('\n') { - if line.is_empty() { - count += 1; - } else { - count += ((line.len() as f64) / (effective as f64)).ceil() as u16; - } - } - count.max(1) } // ── Model picker ───────────────────────────────────────────────────────── @@ -832,12 +779,10 @@ mod tui { frame.render_widget(Paragraph::new(line), area); } - fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + fn render_messages(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let inner_width = area.width.saturating_sub(2); let inner_height = area.height.saturating_sub(2); - app.auto_scroll(inner_height, area.width); - let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)); @@ -914,19 +859,17 @@ mod tui { } } - let total_lines = lines.len() as u16; - let scroll = if total_lines > inner_height { - app.scroll_offset.min(total_lines - inner_height) - } else { - 0 - }; - - frame.render_widget( - Paragraph::new(lines) - .scroll((scroll, 0)) - .wrap(Wrap { trim: false }), - inner, - ); + // Always pin to the bottom so the latest message stays visible. There is + // no scrollback, so we just need the exact number of wrapped rows the + // paragraph occupies at this width — `line_count` runs the same + // WordWrapper as rendering, so it never diverges from what's drawn (an + // estimate would, e.g. by forgetting the `<think>` box lines, and scroll + // too little — the bug this fixes). + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + let total_lines = paragraph.line_count(inner_width) as u16; + let scroll = total_lines.saturating_sub(inner_height); + + frame.render_widget(paragraph.scroll((scroll, 0)), inner); } fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) { @@ -1192,7 +1135,6 @@ mod tui { SlashCommand::Clear => { let cleared = engine.clear_history().await; app.messages.clear(); - app.scroll_offset = 0; app.messages.push(ChatMessage::system(format!( "Cleared {cleared} turn(s). History is empty.", )));