Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions code-rs/tui/src/app/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions code-rs/tui/src/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions code-rs/tui/src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64, TerminalRunState>,

pub(super) terminal_title_override: Option<String>,
Expand Down
14 changes: 14 additions & 0 deletions code-rs/tui/src/app/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
165 changes: 163 additions & 2 deletions code-rs/tui/src/streaming/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<String>>,
start_count: Cell<usize>,
stop_count: Cell<usize>,
}

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<Line<'static>>) {
self.insert_history_with_kind(None, StreamKind::Answer, lines);
}

fn insert_history_with_kind(
&self,
_id: Option<String>,
_kind: StreamKind,
lines: Vec<Line<'static>>,
) {
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<String>,
lines: Vec<Line<'static>>,
_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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions code-rs/tui/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment on lines 109 to +113

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable mouse capture in standard-terminal mode

This unconditionally enables mouse capture in tui::init, but when config.tui.alternate_screen is false the app immediately leaves the alt screen in run_ratatui_app (lib.rs) to run in standard terminal mode. There is no corresponding DisableMouseCapture, and Ctrl+M toggling is now gated on alt_screen_active, so users who start or switch into standard mode cannot turn capture off. That traps scroll-wheel events in the app and breaks normal terminal scrollback/text selection in standard mode, which is the opposite of the intended behavior.

Useful? React with 👍 / 👎.

// 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
Expand Down