Files
codex/prs/bolinfest/PR-1599.md
2025-09-02 15:17:45 -07:00

9.1 KiB

PR #1599: Implement redraw debounce

Description

Summary

  • debouce redraw events so repeated requests don't overwhelm the terminal
  • add RequestRedraw event and schedule redraws after 100ms

Testing

  • cargo clippy --tests
  • cargo test (fails: Sandbox Denied errors in landlock tests)

https://chatgpt.com/codex/tasks/task_i_68792a65b8b483218ec90a8f68746cd8

Full Diff

diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index ac69bef2e9..397c0dc9a4 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -18,8 +18,15 @@ use crossterm::event::KeyEvent;
 use crossterm::event::MouseEvent;
 use crossterm::event::MouseEventKind;
 use std::path::PathBuf;
+use std::sync::Arc;
+use std::sync::Mutex;
 use std::sync::mpsc::Receiver;
 use std::sync::mpsc::channel;
+use std::thread;
+use std::time::Duration;
+
+/// Time window for debouncing redraw requests.
+const REDRAW_DEBOUNCE: Duration = Duration::from_millis(100);
 
 /// Top-level application state: which full-screen view is currently active.
 #[allow(clippy::large_enum_variant)]
@@ -46,6 +53,9 @@ pub(crate) struct App<'a> {
 
     file_search: FileSearchManager,
 
+    /// True when a redraw has been scheduled but not yet executed.
+    pending_redraw: Arc<Mutex<bool>>,
+
     /// Stored parameters needed to instantiate the ChatWidget later, e.g.,
     /// after dismissing the Git-repo warning.
     chat_args: Option<ChatWidgetArgs>,
@@ -70,6 +80,7 @@ impl App<'_> {
     ) -> Self {
         let (app_event_tx, app_event_rx) = channel();
         let app_event_tx = AppEventSender::new(app_event_tx);
+        let pending_redraw = Arc::new(Mutex::new(false));
         let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
 
         // Spawn a dedicated thread for reading the crossterm event loop and
@@ -83,7 +94,7 @@ impl App<'_> {
                             app_event_tx.send(AppEvent::KeyEvent(key_event));
                         }
                         crossterm::event::Event::Resize(_, _) => {
-                            app_event_tx.send(AppEvent::Redraw);
+                            app_event_tx.send(AppEvent::RequestRedraw);
                         }
                         crossterm::event::Event::Mouse(MouseEvent {
                             kind: MouseEventKind::ScrollUp,
@@ -152,6 +163,7 @@ impl App<'_> {
             app_state,
             config,
             file_search,
+            pending_redraw,
             chat_args,
         }
     }
@@ -162,6 +174,29 @@ impl App<'_> {
         self.app_event_tx.clone()
     }
 
+    /// Schedule a redraw if one is not already pending.
+    #[allow(clippy::unwrap_used)]
+    fn schedule_redraw(&self) {
+        {
+            #[allow(clippy::unwrap_used)]
+            let mut flag = self.pending_redraw.lock().unwrap();
+            if *flag {
+                return;
+            }
+            *flag = true;
+        }
+
+        let tx = self.app_event_tx.clone();
+        let pending_redraw = self.pending_redraw.clone();
+        thread::spawn(move || {
+            thread::sleep(REDRAW_DEBOUNCE);
+            tx.send(AppEvent::Redraw);
+            #[allow(clippy::unwrap_used)]
+            let mut f = pending_redraw.lock().unwrap();
+            *f = false;
+        });
+    }
+
     pub(crate) fn run(
         &mut self,
         terminal: &mut tui::Tui,
@@ -169,10 +204,13 @@ impl App<'_> {
     ) -> Result<()> {
         // Insert an event to trigger the first render.
         let app_event_tx = self.app_event_tx.clone();
-        app_event_tx.send(AppEvent::Redraw);
+        app_event_tx.send(AppEvent::RequestRedraw);
 
         while let Ok(event) = self.app_event_rx.recv() {
             match event {
+                AppEvent::RequestRedraw => {
+                    self.schedule_redraw();
+                }
                 AppEvent::Redraw => {
                     self.draw_next_frame(terminal)?;
                 }
@@ -249,7 +287,7 @@ impl App<'_> {
                             Vec::new(),
                         ));
                         self.app_state = AppState::Chat { widget: new_widget };
-                        self.app_event_tx.send(AppEvent::Redraw);
+                        self.app_event_tx.send(AppEvent::RequestRedraw);
                     }
                     SlashCommand::ToggleMouseMode => {
                         if let Err(e) = mouse_capture.toggle() {
@@ -336,7 +374,7 @@ impl App<'_> {
                         args.initial_images,
                     ));
                     self.app_state = AppState::Chat { widget };
-                    self.app_event_tx.send(AppEvent::Redraw);
+                    self.app_event_tx.send(AppEvent::RequestRedraw);
                 }
                 GitWarningOutcome::Quit => {
                     self.app_event_tx.send(AppEvent::ExitRequest);
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
index fd6b2479ee..3aaa789760 100644
--- a/codex-rs/tui/src/app_event.rs
+++ b/codex-rs/tui/src/app_event.rs
@@ -8,6 +8,10 @@ use crate::slash_command::SlashCommand;
 pub(crate) enum AppEvent {
     CodexEvent(Event),
 
+    /// Request a redraw which will be debounced by the [`App`].
+    RequestRedraw,
+
+    /// Actually draw the next frame.
     Redraw,
 
     KeyEvent(KeyEvent),
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index e4ea1d3823..2a91655cc5 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -212,7 +212,7 @@ impl BottomPane<'_> {
     }
 
     pub(crate) fn request_redraw(&self) {
-        self.app_event_tx.send(AppEvent::Redraw)
+        self.app_event_tx.send(AppEvent::RequestRedraw)
     }
 
     /// Returns true when a popup inside the composer is visible.
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 860439ffb6..7c825acd41 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -431,7 +431,7 @@ impl ChatWidget<'_> {
     }
 
     fn request_redraw(&mut self) {
-        self.app_event_tx.send(AppEvent::Redraw);
+        self.app_event_tx.send(AppEvent::RequestRedraw);
     }
 
     pub(crate) fn add_diff_output(&mut self, diff_output: String) {
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
index f9b71a23cb..dda61d0bd0 100644
--- a/codex-rs/tui/src/status_indicator_widget.rs
+++ b/codex-rs/tui/src/status_indicator_widget.rs
@@ -65,7 +65,7 @@ impl StatusIndicatorWidget {
                     std::thread::sleep(Duration::from_millis(200));
                     counter = counter.wrapping_add(1);
                     frame_idx_clone.store(counter, Ordering::Relaxed);
-                    app_event_tx_clone.send(AppEvent::Redraw);
+                    app_event_tx_clone.send(AppEvent::RequestRedraw);
                 }
             });
         }

Review Comments

codex-rs/tui/src/app.rs

@@ -162,17 +174,39 @@ impl<'a> App<'a> {
         self.app_event_tx.clone()
     }
 
+    /// Schedule a redraw if one is not already pending.
+    #[allow(clippy::unwrap_used)]

Can you move this to just above where you use it like on 180?

@@ -162,17 +174,39 @@ impl<'a> App<'a> {
         self.app_event_tx.clone()
     }
 
+    /// Schedule a redraw if one is not already pending.
+    #[allow(clippy::unwrap_used)]
+    fn schedule_redraw(&self) {
+        let mut flag = self.pending_redraw.lock().unwrap();
+        if *flag {
+            return;
+        }
+        *flag = true;
+        let tx = self.app_event_tx.clone();
+        let pending = Arc::clone(&self.pending_redraw);
+        thread::spawn(move || {
+            thread::sleep(REDRAW_DEBOUNCE);
+            tx.send(AppEvent::Redraw);
+            #[allow(clippy::unwrap_used)]
+            let mut f = pending.lock().unwrap();
+            *f = false;
+        });

This is slightly better because it results in holding the lock for a shorter amount of time.

That extra level of scoping around the use of self.pending_redraw.lock() and flag ensures that after *flag = true, the lock is dropped.

Also, Arc::clone() is less canonical than just invoking .clone(), in my experience.

        {
            #[allow(clippy::unwrap_used)]
            let mut flag = self.pending_redraw.lock().unwrap();
            if *flag {
                return;
            }
            *flag = true;
        }

        let tx = self.app_event_tx.clone();
        let pending_redraw = &self.pending_redraw.clone();
        thread::spawn(move || {
            thread::sleep(REDRAW_DEBOUNCE);
            tx.send(AppEvent::Redraw);
            #[allow(clippy::unwrap_used)]
            let mut f = pending.lock().unwrap();
            *f = false;
        });