@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
@@ -30,7 +30,10 @@ tokio-util = { version = "0.7", features = ["compat"] }
30 futures = "0.3"
31
32 # TUI (interactive chat mode)
33 -ratatui = "0.29"
33 +# `unstable-rendered-line-info` exposes `Paragraph::line_count`, which we use to
34 +# pin the message view to the bottom using the exact wrapped-row count (the same
35 +# WordWrapper that rendering uses) rather than a hand-rolled estimate.
36 +ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] }
37 crossterm = { version = "0.29", features = ["event-stream"] }
38
39 # Utilities
src/chat.rs
+12 -70
@@ -163,7 +163,6 @@ mod tui {
163 messages: Vec<ChatMessage>,
164 input: String,
165 cursor: usize,
166 - scroll_offset: u16,
166 stream_rx: Option<mpsc::Receiver<StreamChunk>>,
167 stream_buf: String,
168 inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
@@ -259,7 +258,6 @@ mod tui {
258 messages: Vec::new(),
259 input: String::new(),
260 cursor: 0,
262 - scroll_offset: 0,
261 stream_rx: None,
262 stream_buf: String::new(),
263 inference_rx: None,
@@ -433,57 +431,6 @@ mod tui {
431 }
432 self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
433 }
436 -
437 - /// rough line count for scroll math
438 - fn total_message_lines(&self, width: u16) -> u16 {
439 - if width == 0 {
440 - return 0;
441 - }
442 - let w = width.saturating_sub(2) as usize;
443 - let mut lines: u16 = 0;
444 - for msg in &self.messages {
445 - lines += wrapped_line_count(&msg.text, msg.role, w);
446 - }
447 - if !self.stream_buf.is_empty() {
448 - lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
449 - }
450 - if self.thinking || self.switching_model {
451 - lines += 1;
452 - }
453 - lines
454 - }
455 -
456 - fn auto_scroll(&mut self, visible_height: u16, width: u16) {
457 - let total = self.total_message_lines(width);
458 - if total > visible_height {
459 - self.scroll_offset = total - visible_height;
460 - } else {
461 - self.scroll_offset = 0;
462 - }
463 - }
464 - }
465 -
466 - fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
467 - let prefix_len = match role {
468 - Role::User => 6, // "you > "
469 - Role::Assistant => 8, // "siGit > "
470 - Role::System | Role::Banner => 0,
471 - };
472 - let effective = if width > prefix_len {
473 - width - prefix_len
474 - } else {
475 - 1
476 - };
477 -
478 - let mut count: u16 = 0;
479 - for line in text.split('\n') {
480 - if line.is_empty() {
481 - count += 1;
482 - } else {
483 - count += ((line.len() as f64) / (effective as f64)).ceil() as u16;
484 - }
485 - }
486 - count.max(1)
434 }
435
436 // ── Model picker ─────────────────────────────────────────────────────────
@@ -832,12 +779,10 @@ mod tui {
779 frame.render_widget(Paragraph::new(line), area);
780 }
781
835 - fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
782 + fn render_messages(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
783 let inner_width = area.width.saturating_sub(2);
784 let inner_height = area.height.saturating_sub(2);
785
839 - app.auto_scroll(inner_height, area.width);
840 -
786 let block = Block::default()
787 .borders(Borders::ALL)
788 .border_style(Style::default().fg(Color::DarkGray));
@@ -914,19 +859,17 @@ mod tui {
859 }
860 }
861
917 - let total_lines = lines.len() as u16;
918 - let scroll = if total_lines > inner_height {
919 - app.scroll_offset.min(total_lines - inner_height)
920 - } else {
921 - 0
922 - };
923 -
924 - frame.render_widget(
925 - Paragraph::new(lines)
926 - .scroll((scroll, 0))
927 - .wrap(Wrap { trim: false }),
928 - inner,
929 - );
862 + // Always pin to the bottom so the latest message stays visible. There is
863 + // no scrollback, so we just need the exact number of wrapped rows the
864 + // paragraph occupies at this width — `line_count` runs the same
865 + // WordWrapper as rendering, so it never diverges from what's drawn (an
866 + // estimate would, e.g. by forgetting the `<think>` box lines, and scroll
867 + // too little — the bug this fixes).
868 + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
869 + let total_lines = paragraph.line_count(inner_width) as u16;
870 + let scroll = total_lines.saturating_sub(inner_height);
871 +
872 + frame.render_widget(paragraph.scroll((scroll, 0)), inner);
873 }
874
875 fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
@@ -1192,7 +1135,6 @@ mod tui {
1135 SlashCommand::Clear => {
1136 let cleared = engine.clear_history().await;
1137 app.messages.clear();
1195 - app.scroll_offset = 0;
1138 app.messages.push(ChatMessage::system(format!(
1139 "Cleared {cleared} turn(s). History is empty.",
1140 )));