From a345a956aab9a139e714f41eb17f442c71df7712 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 27 Apr 2026 19:33:52 -0500 Subject: [PATCH] feat(perf): wire frame-rate limiter + adaptive chunking low-motion mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire 120 FPS FrameRateLimiter into run_event_loop via time_until_next_draw + mark_emitted - Add low_motion support: 30 FPS cap via LOW_MOTION_MIN_FRAME_INTERVAL - Add AdaptiveChunkingPolicy::set_low_motion() to force Smooth mode - Add StreamingState::set_low_motion() to propagate to all block policies - Tool spinner already freezes on first frame when low_motion is set TODO_BACKEND.md §3, TODO_FIXES.md #4 --- crates/tui/src/tui/frame_rate_limiter.rs | 61 ++++++++++++++++++++- crates/tui/src/tui/streaming/chunking.rs | 69 ++++++++++++++++++++++++ crates/tui/src/tui/streaming/mod.rs | 9 ++++ crates/tui/src/tui/ui.rs | 7 +++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/frame_rate_limiter.rs b/crates/tui/src/tui/frame_rate_limiter.rs index 69aad9d5..da82b9a9 100644 --- a/crates/tui/src/tui/frame_rate_limiter.rs +++ b/crates/tui/src/tui/frame_rate_limiter.rs @@ -35,12 +35,17 @@ use std::time::Instant; /// 120 FPS minimum frame interval (≈8.33ms). pub const MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(8_333_334); +/// 30 FPS minimum frame interval (≈33.33ms) used in low-motion mode. +pub const LOW_MOTION_MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(33_333_333); + /// Remembers the most recent emitted draw, allowing deadlines to be clamped /// forward so the next draw never lands sooner than `MIN_FRAME_INTERVAL` /// after the last one. #[derive(Debug, Default)] pub struct FrameRateLimiter { last_emitted_at: Option, + /// When true, use the 30 FPS cap instead of 120 FPS. + low_motion: bool, } impl FrameRateLimiter { @@ -52,7 +57,7 @@ impl FrameRateLimiter { return requested; }; let min_allowed = last_emitted_at - .checked_add(MIN_FRAME_INTERVAL) + .checked_add(self.interval()) .unwrap_or(last_emitted_at); requested.max(min_allowed) } @@ -74,6 +79,19 @@ impl FrameRateLimiter { Some(clamped - now) } } + + /// Set low-motion mode: caps frame rate at 30 FPS instead of 120 FPS. + pub fn set_low_motion(&mut self, low_motion: bool) { + self.low_motion = low_motion; + } + + fn interval(&self) -> Duration { + if self.low_motion { + LOW_MOTION_MIN_FRAME_INTERVAL + } else { + MIN_FRAME_INTERVAL + } + } } #[cfg(test)] @@ -124,4 +142,45 @@ mod tests { let well_past = t0 + Duration::from_millis(50); assert!(limiter.time_until_next_draw(well_past).is_none()); } + + #[test] + fn low_motion_clamps_to_30fps_interval() { + let t0 = Instant::now(); + let mut limiter = FrameRateLimiter::default(); + limiter.set_low_motion(true); + limiter.mark_emitted(t0); + + let too_soon = t0 + Duration::from_millis(5); + // Under 30 FPS (~33.33 ms), a draw 5 ms after last emit is clamped. + assert_eq!( + limiter.clamp_deadline(too_soon), + t0 + LOW_MOTION_MIN_FRAME_INTERVAL + ); + + // After 34 ms, draw is allowed. + let after_34 = t0 + Duration::from_millis(34); + assert!(limiter.time_until_next_draw(after_34).is_none()); + } + + #[test] + fn low_motion_switching_respects_current_mode() { + let t0 = Instant::now(); + let mut limiter = FrameRateLimiter::default(); + + // Default (120 FPS): mark at t0, 10 ms later is clamped to ~8.33ms + limiter.mark_emitted(t0); + let t10 = t0 + Duration::from_millis(10); + assert!(limiter.time_until_next_draw(t10).is_none()); // 10ms > 8.33ms + + // Switch to low_motion; mark again + limiter.set_low_motion(true); + limiter.mark_emitted(t10); + let t20 = t10 + Duration::from_millis(10); + let remaining = limiter.time_until_next_draw(t20).unwrap(); + // 30 FPS = 33.33 ms interval; 10ms elapsed → ~23.33 remaining + assert!( + remaining > Duration::from_millis(20) && remaining < Duration::from_millis(25), + "expected ~23.33ms remaining, got {remaining:?}" + ); + } } diff --git a/crates/tui/src/tui/streaming/chunking.rs b/crates/tui/src/tui/streaming/chunking.rs index 37209ba8..6a196277 100644 --- a/crates/tui/src/tui/streaming/chunking.rs +++ b/crates/tui/src/tui/streaming/chunking.rs @@ -90,6 +90,10 @@ pub struct AdaptiveChunkingPolicy { mode: ChunkingMode, below_exit_threshold_since: Option, last_catch_up_exit_at: Option, + /// When true, the policy never enters `CatchUp` — it stays in `Smooth` + /// regardless of queue pressure, keeping the display calm for users who + /// prefer reduced visual churn. + low_motion: bool, } impl AdaptiveChunkingPolicy { @@ -109,8 +113,31 @@ impl AdaptiveChunkingPolicy { self.last_catch_up_exit_at = None; } + /// When true, the policy never enters `CatchUp` — it stays in `Smooth` + /// regardless of queue pressure. + pub fn set_low_motion(&mut self, low_motion: bool) { + self.low_motion = low_motion; + if low_motion { + self.mode = ChunkingMode::Smooth; + self.below_exit_threshold_since = None; + self.last_catch_up_exit_at = None; + } + } + /// Computes a drain decision from the current queue snapshot. pub fn decide(&mut self, snapshot: QueueSnapshot, now: Instant) -> ChunkingDecision { + // In low-motion mode, always use Smooth pacing regardless of queue + // pressure — the user asked for a calm, steady display. + if self.low_motion { + self.mode = ChunkingMode::Smooth; + self.below_exit_threshold_since = None; + return ChunkingDecision { + mode: self.mode, + entered_catch_up: false, + drain_plan: DrainPlan::Single, + }; + } + if snapshot.queued_lines == 0 { self.note_catch_up_exit(now); self.mode = ChunkingMode::Smooth; @@ -351,4 +378,46 @@ mod tests { assert_eq!(severe.mode, ChunkingMode::CatchUp); assert_eq!(severe.drain_plan, DrainPlan::Batch(64)); } + + #[test] + fn low_motion_always_smooth_regardless_of_pressure() { + let mut policy = AdaptiveChunkingPolicy::new(); + policy.set_low_motion(true); + let t0 = Instant::now(); + + // Queue depth far above ENTER threshold. + let d1 = policy.decide(snap(20, 10), t0); + assert_eq!(d1.mode, ChunkingMode::Smooth); + assert!(!d1.entered_catch_up); + assert_eq!(d1.drain_plan, DrainPlan::Single); + + // Oldest age far above ENTER threshold. + let d2 = policy.decide(snap(5, 500), t0 + Duration::from_millis(100)); + assert_eq!(d2.mode, ChunkingMode::Smooth); + assert!(!d2.entered_catch_up); + assert_eq!(d2.drain_plan, DrainPlan::Single); + + // Severe backlog — still Smooth. + let d3 = policy.decide(snap(80, 500), t0 + Duration::from_millis(200)); + assert_eq!(d3.mode, ChunkingMode::Smooth); + assert_eq!(d3.drain_plan, DrainPlan::Single); + } + + #[test] + fn low_motion_reset_resumes_normal_operation() { + let mut policy = AdaptiveChunkingPolicy::new(); + policy.set_low_motion(true); + let t0 = Instant::now(); + + // Low motion blocks catch-up. + let d1 = policy.decide(snap(20, 10), t0); + assert_eq!(d1.mode, ChunkingMode::Smooth); + + // Turn off low motion — next burst should enter CatchUp. + policy.set_low_motion(false); + let d2 = policy.decide(snap(20, 10), t0 + Duration::from_millis(10)); + assert_eq!(d2.mode, ChunkingMode::CatchUp); + assert!(d2.entered_catch_up); + assert_eq!(d2.drain_plan, DrainPlan::Batch(20)); + } } diff --git a/crates/tui/src/tui/streaming/mod.rs b/crates/tui/src/tui/streaming/mod.rs index 7af06b23..0cbe53cd 100644 --- a/crates/tui/src/tui/streaming/mod.rs +++ b/crates/tui/src/tui/streaming/mod.rs @@ -491,6 +491,15 @@ impl StreamingState { result } + /// Propagate the low-motion flag to every block's chunking policy. + /// When true, all policies stay in `Smooth` regardless of queue pressure, + /// preventing CatchUp burst drains that would create sudden visual jumps. + pub fn set_low_motion(&mut self, low_motion: bool) { + for block in self.blocks.iter_mut().flatten() { + block.policy.set_low_motion(low_motion); + } + } + /// Check if any stream is still active fn check_active(&mut self) { self.is_active = self.blocks.iter().any(|b| { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a11a2ccd..38998687 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -954,6 +954,13 @@ async fn run_event_loop( // redraw is needed but the limiter says we're inside the cooldown // window, leave `needs_redraw = true` and shorten the poll timeout // so the loop wakes up exactly when drawing is allowed. + + // Sync low-motion flag into the frame-rate limiter and streaming + // chunking policy. Low-motion mode drops the frame cap to 30 FPS + // and forces Smooth-only chunking so the display stays calm. + frame_rate_limiter.set_low_motion(app.low_motion); + app.streaming_state.set_low_motion(app.low_motion); + let draw_wait = if app.needs_redraw { frame_rate_limiter.time_until_next_draw(now) } else {