From 41c363ddfc917ffef2eda4b098303ca6bf0c1ddf Mon Sep 17 00:00:00 2001 From: Jake Weinstein Date: Tue, 20 Jan 2026 15:33:30 -0600 Subject: [PATCH 1/2] fix(tui/streaming): avoid frozen output without newlines Keep commit ticks running when timeout-based soft commits are enabled and the model has buffered content but hasn't emitted a newline yet. Add unit tests that cover the initial-delta timeout path and ensure the commit animation isn't stopped prematurely. --- code-rs/tui/src/streaming/controller.rs | 165 +++++++++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/code-rs/tui/src/streaming/controller.rs b/code-rs/tui/src/streaming/controller.rs index 8956dad2ad2..355af9bf8c0 100644 --- a/code-rs/tui/src/streaming/controller.rs +++ b/code-rs/tui/src/streaming/controller.rs @@ -3,6 +3,7 @@ use code_core::config::Config; use ratatui::text::Line; use ratatui::style::Modifier; +use std::time::Instant; use super::HeaderEmitter; use super::StreamKind; @@ -59,6 +60,125 @@ pub(crate) struct StreamController { thinking_placeholder_shown: bool, } +#[cfg(test)] +mod tests { + use super::*; + use code_core::config::{Config, ConfigOverrides, ConfigToml}; + use std::cell::{Cell, RefCell}; + use std::time::{Duration, Instant}; + + #[derive(Default)] + struct TestSink { + inserts: RefCell>, + start_count: Cell, + stop_count: Cell, + } + + impl TestSink { + fn contains_text(&self, needle: &str) -> bool { + self.inserts + .borrow() + .iter() + .any(|s| s.contains(needle)) + } + } + + impl HistorySink for TestSink { + fn insert_history(&self, lines: Vec>) { + self.insert_history_with_kind(None, StreamKind::Answer, lines); + } + + fn insert_history_with_kind( + &self, + _id: Option, + _kind: StreamKind, + lines: Vec>, + ) { + let mut out = String::new(); + for (idx, line) in lines.iter().enumerate() { + if idx > 0 { + out.push('\n'); + } + for span in &line.spans { + out.push_str(span.content.as_ref()); + } + } + self.inserts.borrow_mut().push(out); + } + + fn insert_final_answer( + &self, + _id: Option, + lines: Vec>, + _full_markdown_source: String, + ) { + self.insert_history(lines); + } + + fn start_commit_animation(&self) { + self.start_count + .set(self.start_count.get().saturating_add(1)); + } + + fn stop_commit_animation(&self) { + self.stop_count + .set(self.stop_count.get().saturating_add(1)); + } + } + + fn test_config() -> Config { + Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .expect("config") + } + + #[test] + fn timeout_soft_commit_emits_initial_output() { + let mut cfg = test_config(); + cfg.tui.stream.soft_commit_timeout_ms = Some(0); + + let mut controller = StreamController::new(cfg); + let sink = TestSink::default(); + + controller.begin_with_id(StreamKind::Answer, Some("a".to_string()), &sink); + controller.push_and_maybe_commit("Hello", &sink); + + // With timeout commits enabled, streaming should be driven by commit ticks even when + // no newline has been received yet. + assert!(sink.start_count.get() > 0); + + controller.on_commit_tick(&sink); + assert!(sink.contains_text("Hello")); + } + + #[test] + fn commit_animation_keeps_running_while_buffered_and_timeout_enabled() { + let mut cfg = test_config(); + cfg.tui.stream.soft_commit_timeout_ms = Some(60_000); + + let mut controller = StreamController::new(cfg); + let sink = TestSink::default(); + + controller.begin_with_id(StreamKind::Answer, Some("a".to_string()), &sink); + controller.push_and_maybe_commit("Hello", &sink); + assert!(sink.start_count.get() > 0); + + // First tick is not overdue; the controller should not stop the animation while the + // collector is still holding uncommitted content. + controller.on_commit_tick(&sink); + assert_eq!(sink.stop_count.get(), 0); + + // Force overdue and ensure we can soft-commit and eventually stop. + controller.state_mut(StreamKind::Answer).last_commit_instant = + Some(Instant::now() - Duration::from_secs(120)); + controller.on_commit_tick(&sink); + assert!(sink.contains_text("Hello")); + } +} + impl StreamController { pub(crate) fn new(config: Config) -> Self { Self { @@ -284,9 +404,18 @@ pub(crate) fn set_last_sequence_number(&mut self, kind: StreamKind, seq: Option< tracing::debug!("push_and_maybe_commit for {:?}, delta.len={} contains_nl={}", kind, delta.len(), delta.contains('\n')); let cfg = self.config.clone(); + let timeout_ms = self + .config + .tui + .stream + .soft_commit_timeout_ms + .or(if self.config.tui.stream.responsive { Some(400) } else { None }); + let has_timeout_soft_commit = timeout_ms.is_some(); + // Check header flag before borrowing state (used only to avoid double headers) let _just_emitted_header = self.header.consume_header_flag(); + let mut should_start_commit_animation = false; // Mutate collector and counters in a short scope to avoid long mutable borrows. { let state = self.state_mut(kind); @@ -295,6 +424,27 @@ pub(crate) fn set_last_sequence_number(&mut self, kind: StreamKind, seq: Option< } state.collector.push_delta(delta); state.tail_chars_since_commit = state.tail_chars_since_commit.saturating_add(delta.len()); + + // Timeout-based soft commits are driven by commit ticks. Historically we only started + // commit animation after we had already committed at least one line, which meant the + // timeout path never fired for the *first* chunk of output (no newline, short line). + // + // Anchor the timeout window on the first delta and keep commit ticks running so the + // UI can surface output without requiring user interaction (like toggling screen mode). + if has_timeout_soft_commit + && !delta.is_empty() + && !delta.contains('\n') + && state.collector.has_buffered_content() + { + if state.last_commit_instant.is_none() { + state.last_commit_instant = Some(Instant::now()); + } + should_start_commit_animation = true; + } + } + + if should_start_commit_animation { + sink.start_commit_animation(); } if delta.contains('\n') { let mut newly_completed = self.state_mut(kind).collector.commit_complete_lines(&cfg); @@ -630,8 +780,13 @@ pub(crate) fn set_last_sequence_number(&mut self, kind: StreamKind, seq: Option< return false; }; // Timeout-based soft commit: if no newline arrived and nothing is queued, force a soft commit. - let timeout_ms = self.config.tui.stream.soft_commit_timeout_ms + let timeout_ms = self + .config + .tui + .stream + .soft_commit_timeout_ms .or(if self.config.tui.stream.responsive { Some(400) } else { None }); + let has_timeout_soft_commit = timeout_ms.is_some(); if let Some(ms) = timeout_ms { let queue_empty = self.state(kind).is_idle(); let overdue = self @@ -709,8 +864,14 @@ pub(crate) fn set_last_sequence_number(&mut self, kind: StreamKind, seq: Option< } let is_idle = self.state(kind).is_idle(); + let has_buffered = self.state(kind).collector.has_buffered_content(); if is_idle { - sink.stop_commit_animation(); + // When timeout-based soft commit is enabled, keep commit ticks running while the + // collector has buffered content (even if no full lines are enqueued yet). Otherwise, + // we'd stop the animation on the first tick and the timeout path would never fire. + if !(has_timeout_soft_commit && has_buffered) { + sink.stop_commit_animation(); + } if self.finishing_after_drain { // Reset and notify self.state_mut(kind).clear(); From 4b81515e6c5afb9e249bfa5121a2139f926bde19 Mon Sep 17 00:00:00 2001 From: Jake Weinstein Date: Tue, 20 Jan 2026 15:33:37 -0600 Subject: [PATCH 2/2] feat(tui/input): capture mouse wheel in full UI Enable mouse capture in the alternate-screen UI so wheel/trackpad scrolling scrolls the chat/history instead of the terminal scrollback. Track capture state on the App (no static mut) and keep image paste behavior unchanged. --- code-rs/tui/src/app/events.rs | 24 +++++++++++++----------- code-rs/tui/src/app/init.rs | 1 + code-rs/tui/src/app/state.rs | 6 ++++++ code-rs/tui/src/app/terminal.rs | 14 ++++++++++++++ code-rs/tui/src/tui.rs | 5 +++++ 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/code-rs/tui/src/app/events.rs b/code-rs/tui/src/app/events.rs index d3532626b8c..4d36e1eeb37 100644 --- a/code-rs/tui/src/app/events.rs +++ b/code-rs/tui/src/app/events.rs @@ -444,18 +444,20 @@ impl App<'_> { kind: KeyEventKind::Press, .. } => { - // Toggle mouse capture to allow text selection - use crossterm::event::DisableMouseCapture; - use crossterm::event::EnableMouseCapture; - use crossterm::execute; - use std::io::stdout; - - // Static variable to track mouse capture state - static mut MOUSE_CAPTURE_ENABLED: bool = true; + // Toggle mouse capture to allow text selection. + // + // In full UI (alt screen), mouse capture ensures scroll events are + // delivered to the app so the chat scrolls instead of the terminal. + // In standard terminal mode, we intentionally leave mouse capture + // disabled so the terminal's normal scrollback works. + if self.alt_screen_active { + use crossterm::event::DisableMouseCapture; + use crossterm::event::EnableMouseCapture; + use crossterm::execute; + use std::io::stdout; - unsafe { - MOUSE_CAPTURE_ENABLED = !MOUSE_CAPTURE_ENABLED; - if MOUSE_CAPTURE_ENABLED { + self.mouse_capture_enabled = !self.mouse_capture_enabled; + if self.mouse_capture_enabled { let _ = execute!(stdout(), EnableMouseCapture); } else { let _ = execute!(stdout(), DisableMouseCapture); diff --git a/code-rs/tui/src/app/init.rs b/code-rs/tui/src/app/init.rs index 1c2cefd04a6..4035dc7d190 100644 --- a/code-rs/tui/src/app/init.rs +++ b/code-rs/tui/src/app/init.rs @@ -330,6 +330,7 @@ impl App<'_> { timing: super::state::TimingStats::default(), buffer_diff_profiler: super::state::BufferDiffProfiler::new_from_env(), alt_screen_active: start_in_alt, + mouse_capture_enabled: true, terminal_runs: HashMap::new(), terminal_title_override: None, login_flow: None, diff --git a/code-rs/tui/src/app/state.rs b/code-rs/tui/src/app/state.rs index c55bb2b28a6..96535d94f4c 100644 --- a/code-rs/tui/src/app/state.rs +++ b/code-rs/tui/src/app/state.rs @@ -272,6 +272,12 @@ pub(crate) struct App<'a> { /// True when TUI is currently rendering in the terminal's alternate screen. pub(super) alt_screen_active: bool, + /// When true, mouse wheel scroll events are captured by the app (full UI) and + /// used to scroll the chat/history instead of the terminal emulator scrollback. + /// + /// This is only meaningful while `alt_screen_active` is true. + pub(super) mouse_capture_enabled: bool, + pub(super) terminal_runs: HashMap, pub(super) terminal_title_override: Option, diff --git a/code-rs/tui/src/app/terminal.rs b/code-rs/tui/src/app/terminal.rs index c819751fbda..858fc964ae5 100644 --- a/code-rs/tui/src/app/terminal.rs +++ b/code-rs/tui/src/app/terminal.rs @@ -535,6 +535,20 @@ impl App<'_> { let fg = crate::colors::text(); let bg = crate::colors::background(); let _ = crate::tui::enter_alt_screen_only(fg, bg); + + // When entering full UI, capture mouse wheel scroll so it scrolls the + // in-app chat/history instead of the terminal scrollback. + if self.mouse_capture_enabled { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::EnableMouseCapture + ); + } else { + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::event::DisableMouseCapture + ); + } self.clear_on_first_frame = true; self.alt_screen_active = true; // Persist preference diff --git a/code-rs/tui/src/tui.rs b/code-rs/tui/src/tui.rs index d8ed7386b39..cd3c6536cac 100644 --- a/code-rs/tui/src/tui.rs +++ b/code-rs/tui/src/tui.rs @@ -11,6 +11,7 @@ use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableMouseCapture; use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; +use crossterm::event::EnableMouseCapture; use crossterm::event::EnableFocusChange; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; @@ -106,6 +107,10 @@ pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> { let terminal_info = query_terminal_info(); enable_raw_mode()?; + // Capture mouse wheel scrolls so the full UI can scroll the chat/history + // instead of letting the terminal emulator scrollback take over. + // Users can toggle this off (Ctrl+M) for text selection. + let _ = execute!(stdout(), EnableMouseCapture); // Enable keyboard enhancement flags only when supported *and* the current // terminal environment is known to handle them reliably. Some Windows // terminal stacks (including WSL/ConPTY-derived PTYs) can report support