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

61 KiB
Raw Blame History

PR #2489: tui: switch to using tokio + EventStream for processing crossterm events

Description

bringing the tui more into tokio-land to make it easier to factorize.

fyi @bolinfest

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index dc9385d990..d879d196ff 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -961,6 +961,7 @@ dependencies = [
  "supports-color",
  "textwrap 0.16.2",
  "tokio",
+ "tokio-stream",
  "tracing",
  "tracing-appender",
  "tracing-subscriber",
@@ -1161,6 +1162,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
 dependencies = [
  "bitflags 2.9.1",
  "crossterm_winapi",
+ "futures-core",
  "mio",
  "parking_lot",
  "rustix 0.38.44",
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 9e0e31e172..b5ec8d04ec 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -38,7 +38,7 @@ codex-login = { path = "../login" }
 codex-ollama = { path = "../ollama" }
 codex-protocol = { path = "../protocol" }
 color-eyre = "0.6.3"
-crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
+crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] }
 diffy = "0.4.2"
 image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
 lazy_static = "1"
@@ -68,6 +68,7 @@ tokio = { version = "1", features = [
     "rt-multi-thread",
     "signal",
 ] }
+tokio-stream = "0.1.17"
 tracing = { version = "0.1.41", features = ["log"] }
 tracing-appender = "0.2.3"
 tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index c7a1693617..6ce51e5dbe 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -27,11 +27,12 @@ use std::path::PathBuf;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 use std::sync::atomic::Ordering;
-use std::sync::mpsc::Receiver;
-use std::sync::mpsc::channel;
 use std::thread;
 use std::time::Duration;
 use std::time::Instant;
+use tokio::select;
+use tokio::sync::mpsc::UnboundedReceiver;
+use tokio::sync::mpsc::unbounded_channel;
 
 /// Time window for debouncing redraw requests.
 const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
@@ -53,7 +54,7 @@ enum AppState<'a> {
 pub(crate) struct App<'a> {
     server: Arc<ConversationManager>,
     app_event_tx: AppEventSender,
-    app_event_rx: Receiver<AppEvent>,
+    app_event_rx: UnboundedReceiver<AppEvent>,
     app_state: AppState<'a>,
 
     /// Config is stored here so we can recreate ChatWidgets as needed.
@@ -92,52 +93,11 @@ impl App<'_> {
     ) -> Self {
         let conversation_manager = Arc::new(ConversationManager::default());
 
-        let (app_event_tx, app_event_rx) = channel();
+        let (app_event_tx, app_event_rx) = unbounded_channel();
         let app_event_tx = AppEventSender::new(app_event_tx);
 
         let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
 
-        // Spawn a dedicated thread for reading the crossterm event loop and
-        // re-publishing the events as AppEvents, as appropriate.
-        {
-            let app_event_tx = app_event_tx.clone();
-            std::thread::spawn(move || {
-                loop {
-                    // This timeout is necessary to avoid holding the event lock
-                    // that crossterm::event::read() acquires. In particular,
-                    // reading the cursor position (crossterm::cursor::position())
-                    // needs to acquire the event lock, and so will fail if it
-                    // can't acquire it within 2 sec. Resizing the terminal
-                    // crashes the app if the cursor position can't be read.
-                    if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
-                        if let Ok(event) = crossterm::event::read() {
-                            match event {
-                                crossterm::event::Event::Key(key_event) => {
-                                    app_event_tx.send(AppEvent::KeyEvent(key_event));
-                                }
-                                crossterm::event::Event::Resize(_, _) => {
-                                    app_event_tx.send(AppEvent::RequestRedraw);
-                                }
-                                crossterm::event::Event::Paste(pasted) => {
-                                    // Many terminals convert newlines to \r when pasting (e.g., iTerm2),
-                                    // but tui-textarea expects \n. Normalize CR to LF.
-                                    // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
-                                    // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
-                                    let pasted = pasted.replace("\r", "\n");
-                                    app_event_tx.send(AppEvent::Paste(pasted));
-                                }
-                                _ => {
-                                    // Ignore any other events.
-                                }
-                            }
-                        }
-                    } else {
-                        // Timeout expired, no `Event` is available
-                    }
-                }
-            });
-        }
-
         let login_status = get_login_status(&config);
         let should_show_onboarding =
             should_show_onboarding(login_status, &config, show_trust_screen);
@@ -179,7 +139,7 @@ impl App<'_> {
         // Spawn a single scheduler thread that coalesces both debounced redraw
         // requests and animation frame requests, and emits a single Redraw event
         // at the earliest requested time.
-        let (frame_tx, frame_rx) = channel::<Instant>();
+        let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Instant>();
         {
             let app_event_tx = app_event_tx.clone();
             std::thread::spawn(move || {
@@ -234,306 +194,338 @@ impl App<'_> {
         let _ = self.frame_schedule_tx.send(Instant::now() + dur);
     }
 
-    pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
-        // Schedule the first render immediately.
-        let _ = self.frame_schedule_tx.send(Instant::now());
+    pub(crate) async fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
+        use tokio_stream::StreamExt;
 
-        while let Ok(event) = self.app_event_rx.recv() {
-            match event {
-                AppEvent::InsertHistory(lines) => {
-                    self.pending_history_lines.extend(lines);
-                    self.app_event_tx.send(AppEvent::RequestRedraw);
-                }
-                AppEvent::RequestRedraw => {
-                    self.schedule_frame_in(REDRAW_DEBOUNCE);
-                }
-                AppEvent::ScheduleFrameIn(dur) => {
-                    self.schedule_frame_in(dur);
-                }
-                AppEvent::Redraw => {
-                    std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
-                }
-                AppEvent::StartCommitAnimation => {
-                    if self
-                        .commit_anim_running
-                        .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
-                        .is_ok()
-                    {
-                        let tx = self.app_event_tx.clone();
-                        let running = self.commit_anim_running.clone();
-                        thread::spawn(move || {
-                            while running.load(Ordering::Relaxed) {
-                                thread::sleep(Duration::from_millis(50));
-                                tx.send(AppEvent::CommitTick);
-                            }
-                        });
-                    }
-                }
-                AppEvent::StopCommitAnimation => {
-                    self.commit_anim_running.store(false, Ordering::Release);
-                }
-                AppEvent::CommitTick => {
-                    if let AppState::Chat { widget } = &mut self.app_state {
-                        widget.on_commit_tick();
-                    }
-                }
-                AppEvent::KeyEvent(key_event) => {
-                    match key_event {
-                        KeyEvent {
-                            code: KeyCode::Char('c'),
-                            modifiers: crossterm::event::KeyModifiers::CONTROL,
-                            kind: KeyEventKind::Press,
-                            ..
-                        } => match &mut self.app_state {
-                            AppState::Chat { widget } => {
-                                widget.on_ctrl_c();
-                            }
-                            AppState::Onboarding { .. } => {
-                                self.app_event_tx.send(AppEvent::ExitRequest);
-                            }
-                        },
-                        KeyEvent {
-                            code: KeyCode::Char('z'),
-                            modifiers: crossterm::event::KeyModifiers::CONTROL,
-                            kind: KeyEventKind::Press,
-                            ..
-                        } => {
-                            #[cfg(unix)]
-                            {
-                                self.suspend(terminal)?;
-                            }
-                            // No-op on non-Unix platforms.
+        self.handle_event(terminal, AppEvent::Redraw)?;
+
+        let mut crossterm_events = crossterm::event::EventStream::new();
+
+        while let Some(event) = {
+            select! {
+                maybe_app_event = self.app_event_rx.recv() => {
+                    maybe_app_event
+                },
+                Some(Ok(event)) = crossterm_events.next() => {
+                    match event {
+                        crossterm::event::Event::Key(key_event) => {
+                            Some(AppEvent::KeyEvent(key_event))
                         }
-                        KeyEvent {
-                            code: KeyCode::Char('d'),
-                            modifiers: crossterm::event::KeyModifiers::CONTROL,
-                            kind: KeyEventKind::Press,
-                            ..
-                        } => {
-                            match &mut self.app_state {
-                                AppState::Chat { widget } => {
-                                    if widget.composer_is_empty() {
-                                        self.app_event_tx.send(AppEvent::ExitRequest);
-                                    } else {
-                                        // Treat Ctrl+D as a normal key event when the composer
-                                        // is not empty so that it doesn't quit the application
-                                        // prematurely.
-                                        self.dispatch_key_event(key_event);
-                                    }
-                                }
-                                AppState::Onboarding { .. } => {
-                                    self.app_event_tx.send(AppEvent::ExitRequest);
-                                }
-                            }
+                        crossterm::event::Event::Resize(_, _) => {
+                            Some(AppEvent::Redraw)
                         }
-                        KeyEvent {
-                            kind: KeyEventKind::Press | KeyEventKind::Repeat,
-                            ..
-                        } => {
-                            self.dispatch_key_event(key_event);
+                        crossterm::event::Event::Paste(pasted) => {
+                            // Many terminals convert newlines to \r when pasting (e.g., iTerm2),
+                            // but tui-textarea expects \n. Normalize CR to LF.
+                            // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
+                            // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
+                            let pasted = pasted.replace("\r", "\n");
+                            Some(AppEvent::Paste(pasted))
                         }
                         _ => {
-                            // Ignore Release key events.
+                            // Ignore any other events.
+                            None
                         }
-                    };
-                }
-                AppEvent::Paste(text) => {
-                    self.dispatch_paste_event(text);
-                }
-                AppEvent::CodexEvent(event) => {
-                    self.dispatch_codex_event(event);
-                }
-                AppEvent::ExitRequest => {
-                    break;
-                }
-                AppEvent::CodexOp(op) => match &mut self.app_state {
-                    AppState::Chat { widget } => widget.submit_op(op),
-                    AppState::Onboarding { .. } => {}
-                },
-                AppEvent::DiffResult(text) => {
-                    if let AppState::Chat { widget } = &mut self.app_state {
-                        widget.add_diff_output(text);
                     }
-                }
-                AppEvent::DispatchCommand(command) => match command {
-                    SlashCommand::New => {
-                        // User accepted  switch to chat view.
-                        let new_widget = Box::new(ChatWidget::new(
-                            self.config.clone(),
-                            self.server.clone(),
-                            self.app_event_tx.clone(),
-                            None,
-                            Vec::new(),
-                            self.enhanced_keys_supported,
-                        ));
-                        self.app_state = AppState::Chat { widget: new_widget };
-                        self.app_event_tx.send(AppEvent::RequestRedraw);
-                    }
-                    SlashCommand::Init => {
-                        // Guard: do not run if a task is active.
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
-                            widget.submit_text_message(INIT_PROMPT.to_string());
-                        }
-                    }
-                    SlashCommand::Compact => {
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            widget.clear_token_usage();
-                            self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
+                },
+            }
+        } && self.handle_event(terminal, event)?
+        {}
+        terminal.clear()?;
+        Ok(())
+    }
+
+    fn handle_event(&mut self, terminal: &mut tui::Tui, event: AppEvent) -> Result<bool> {
+        match event {
+            AppEvent::InsertHistory(lines) => {
+                self.pending_history_lines.extend(lines);
+                self.app_event_tx.send(AppEvent::RequestRedraw);
+            }
+            AppEvent::RequestRedraw => {
+                self.schedule_frame_in(REDRAW_DEBOUNCE);
+            }
+            AppEvent::ScheduleFrameIn(dur) => {
+                self.schedule_frame_in(dur);
+            }
+            AppEvent::Redraw => {
+                std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
+            }
+            AppEvent::StartCommitAnimation => {
+                if self
+                    .commit_anim_running
+                    .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
+                    .is_ok()
+                {
+                    let tx = self.app_event_tx.clone();
+                    let running = self.commit_anim_running.clone();
+                    thread::spawn(move || {
+                        while running.load(Ordering::Relaxed) {
+                            thread::sleep(Duration::from_millis(50));
+                            tx.send(AppEvent::CommitTick);
                         }
-                    }
-                    SlashCommand::Model => {
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            widget.open_model_popup();
+                    });
+                }
+            }
+            AppEvent::StopCommitAnimation => {
+                self.commit_anim_running.store(false, Ordering::Release);
+            }
+            AppEvent::CommitTick => {
+                if let AppState::Chat { widget } = &mut self.app_state {
+                    widget.on_commit_tick();
+                }
+            }
+            AppEvent::KeyEvent(key_event) => {
+                match key_event {
+                    KeyEvent {
+                        code: KeyCode::Char('c'),
+                        modifiers: crossterm::event::KeyModifiers::CONTROL,
+                        kind: KeyEventKind::Press,
+                        ..
+                    } => match &mut self.app_state {
+                        AppState::Chat { widget } => {
+                            widget.on_ctrl_c();
                         }
-                    }
-                    SlashCommand::Approvals => {
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            widget.open_approvals_popup();
+                        AppState::Onboarding { .. } => {
+                            self.app_event_tx.send(AppEvent::ExitRequest);
                         }
-                    }
-                    SlashCommand::Quit => {
-                        break;
-                    }
-                    SlashCommand::Logout => {
-                        if let Err(e) = codex_login::logout(&self.config.codex_home) {
-                            tracing::error!("failed to logout: {e}");
+                    },
+                    KeyEvent {
+                        code: KeyCode::Char('z'),
+                        modifiers: crossterm::event::KeyModifiers::CONTROL,
+                        kind: KeyEventKind::Press,
+                        ..
+                    } => {
+                        #[cfg(unix)]
+                        {
+                            self.suspend(terminal)?;
                         }
-                        break;
+                        // No-op on non-Unix platforms.
                     }
-                    SlashCommand::Diff => {
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            widget.add_diff_in_progress();
-                        }
-
-                        let tx = self.app_event_tx.clone();
-                        tokio::spawn(async move {
-                            let text = match get_git_diff().await {
-                                Ok((is_git_repo, diff_text)) => {
-                                    if is_git_repo {
-                                        diff_text
-                                    } else {
-                                        "`/diff` — _not inside a git repository_".to_string()
-                                    }
+                    KeyEvent {
+                        code: KeyCode::Char('d'),
+                        modifiers: crossterm::event::KeyModifiers::CONTROL,
+                        kind: KeyEventKind::Press,
+                        ..
+                    } => {
+                        match &mut self.app_state {
+                            AppState::Chat { widget } => {
+                                if widget.composer_is_empty() {
+                                    self.app_event_tx.send(AppEvent::ExitRequest);
+                                } else {
+                                    // Treat Ctrl+D as a normal key event when the composer
+                                    // is not empty so that it doesn't quit the application
+                                    // prematurely.
+                                    self.dispatch_key_event(key_event);
                                 }
-                                Err(e) => format!("Failed to compute diff: {e}"),
-                            };
-                            tx.send(AppEvent::DiffResult(text));
-                        });
-                    }
-                    SlashCommand::Mention => {
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            widget.insert_str("@");
-                        }
-                    }
-                    SlashCommand::Status => {
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            widget.add_status_output();
+                            }
+                            AppState::Onboarding { .. } => {
+                                self.app_event_tx.send(AppEvent::ExitRequest);
+                            }
                         }
                     }
-                    SlashCommand::Mcp => {
-                        if let AppState::Chat { widget } = &mut self.app_state {
-                            widget.add_mcp_output();
-                        }
+                    KeyEvent {
+                        kind: KeyEventKind::Press | KeyEventKind::Repeat,
+                        ..
+                    } => {
+                        self.dispatch_key_event(key_event);
                     }
-                    #[cfg(debug_assertions)]
-                    SlashCommand::TestApproval => {
-                        use codex_core::protocol::EventMsg;
-                        use std::collections::HashMap;
-
-                        use codex_core::protocol::ApplyPatchApprovalRequestEvent;
-                        use codex_core::protocol::FileChange;
-
-                        self.app_event_tx.send(AppEvent::CodexEvent(Event {
-                            id: "1".to_string(),
-                            // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
-                            //     call_id: "1".to_string(),
-                            //     command: vec!["git".into(), "apply".into()],
-                            //     cwd: self.config.cwd.clone(),
-                            //     reason: Some("test".to_string()),
-                            // }),
-                            msg: EventMsg::ApplyPatchApprovalRequest(
-                                ApplyPatchApprovalRequestEvent {
-                                    call_id: "1".to_string(),
-                                    changes: HashMap::from([
-                                        (
-                                            PathBuf::from("/tmp/test.txt"),
-                                            FileChange::Add {
-                                                content: "test".to_string(),
-                                            },
-                                        ),
-                                        (
-                                            PathBuf::from("/tmp/test2.txt"),
-                                            FileChange::Update {
-                                                unified_diff: "+test\n-test2".to_string(),
-                                                move_path: None,
-                                            },
-                                        ),
-                                    ]),
-                                    reason: None,
-                                    grant_root: Some(PathBuf::from("/tmp")),
-                                },
-                            ),
-                        }));
+                    _ => {
+                        // Ignore Release key events.
                     }
-                },
-                AppEvent::OnboardingAuthComplete(result) => {
-                    if let AppState::Onboarding { screen } = &mut self.app_state {
-                        screen.on_auth_complete(result);
+                };
+            }
+            AppEvent::Paste(text) => {
+                self.dispatch_paste_event(text);
+            }
+            AppEvent::CodexEvent(event) => {
+                self.dispatch_codex_event(event);
+            }
+            AppEvent::ExitRequest => {
+                return Ok(false);
+            }
+            AppEvent::CodexOp(op) => match &mut self.app_state {
+                AppState::Chat { widget } => widget.submit_op(op),
+                AppState::Onboarding { .. } => {}
+            },
+            AppEvent::DiffResult(text) => {
+                if let AppState::Chat { widget } = &mut self.app_state {
+                    widget.add_diff_output(text);
+                }
+            }
+            AppEvent::DispatchCommand(command) => match command {
+                SlashCommand::New => {
+                    // User accepted  switch to chat view.
+                    let new_widget = Box::new(ChatWidget::new(
+                        self.config.clone(),
+                        self.server.clone(),
+                        self.app_event_tx.clone(),
+                        None,
+                        Vec::new(),
+                        self.enhanced_keys_supported,
+                    ));
+                    self.app_state = AppState::Chat { widget: new_widget };
+                    self.app_event_tx.send(AppEvent::RequestRedraw);
+                }
+                SlashCommand::Init => {
+                    // Guard: do not run if a task is active.
+                    if let AppState::Chat { widget } = &mut self.app_state {
+                        const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
+                        widget.submit_text_message(INIT_PROMPT.to_string());
                     }
                 }
-                AppEvent::OnboardingComplete(ChatWidgetArgs {
-                    config,
-                    enhanced_keys_supported,
-                    initial_images,
-                    initial_prompt,
-                }) => {
-                    self.app_state = AppState::Chat {
-                        widget: Box::new(ChatWidget::new(
-                            config,
-                            self.server.clone(),
-                            self.app_event_tx.clone(),
-                            initial_prompt,
-                            initial_images,
-                            enhanced_keys_supported,
-                        )),
+                SlashCommand::Compact => {
+                    if let AppState::Chat { widget } = &mut self.app_state {
+                        widget.clear_token_usage();
+                        self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
                     }
                 }
-                AppEvent::StartFileSearch(query) => {
-                    if !query.is_empty() {
-                        self.file_search.on_user_query(query);
+                SlashCommand::Model => {
+                    if let AppState::Chat { widget } = &mut self.app_state {
+                        widget.open_model_popup();
                     }
                 }
-                AppEvent::FileSearchResult { query, matches } => {
+                SlashCommand::Approvals => {
                     if let AppState::Chat { widget } = &mut self.app_state {
-                        widget.apply_file_search_result(query, matches);
+                        widget.open_approvals_popup();
+                    }
+                }
+                SlashCommand::Quit => {
+                    return Ok(false);
+                }
+                SlashCommand::Logout => {
+                    if let Err(e) = codex_login::logout(&self.config.codex_home) {
+                        tracing::error!("failed to logout: {e}");
                     }
+                    return Ok(false);
                 }
-                AppEvent::UpdateReasoningEffort(effort) => {
+                SlashCommand::Diff => {
                     if let AppState::Chat { widget } = &mut self.app_state {
-                        widget.set_reasoning_effort(effort);
+                        widget.add_diff_in_progress();
                     }
+
+                    let tx = self.app_event_tx.clone();
+                    tokio::spawn(async move {
+                        let text = match get_git_diff().await {
+                            Ok((is_git_repo, diff_text)) => {
+                                if is_git_repo {
+                                    diff_text
+                                } else {
+                                    "`/diff` — _not inside a git repository_".to_string()
+                                }
+                            }
+                            Err(e) => format!("Failed to compute diff: {e}"),
+                        };
+                        tx.send(AppEvent::DiffResult(text));
+                    });
                 }
-                AppEvent::UpdateModel(model) => {
+                SlashCommand::Mention => {
                     if let AppState::Chat { widget } = &mut self.app_state {
-                        widget.set_model(model);
+                        widget.insert_str("@");
                     }
                 }
-                AppEvent::UpdateAskForApprovalPolicy(policy) => {
+                SlashCommand::Status => {
                     if let AppState::Chat { widget } = &mut self.app_state {
-                        widget.set_approval_policy(policy);
+                        widget.add_status_output();
                     }
                 }
-                AppEvent::UpdateSandboxPolicy(policy) => {
+                SlashCommand::Mcp => {
                     if let AppState::Chat { widget } = &mut self.app_state {
-                        widget.set_sandbox_policy(policy);
+                        widget.add_mcp_output();
                     }
                 }
+                #[cfg(debug_assertions)]
+                SlashCommand::TestApproval => {
+                    use codex_core::protocol::EventMsg;
+                    use std::collections::HashMap;
+
+                    use codex_core::protocol::ApplyPatchApprovalRequestEvent;
+                    use codex_core::protocol::FileChange;
+
+                    self.app_event_tx.send(AppEvent::CodexEvent(Event {
+                        id: "1".to_string(),
+                        // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
+                        //     call_id: "1".to_string(),
+                        //     command: vec!["git".into(), "apply".into()],
+                        //     cwd: self.config.cwd.clone(),
+                        //     reason: Some("test".to_string()),
+                        // }),
+                        msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
+                            call_id: "1".to_string(),
+                            changes: HashMap::from([
+                                (
+                                    PathBuf::from("/tmp/test.txt"),
+                                    FileChange::Add {
+                                        content: "test".to_string(),
+                                    },
+                                ),
+                                (
+                                    PathBuf::from("/tmp/test2.txt"),
+                                    FileChange::Update {
+                                        unified_diff: "+test\n-test2".to_string(),
+                                        move_path: None,
+                                    },
+                                ),
+                            ]),
+                            reason: None,
+                            grant_root: Some(PathBuf::from("/tmp")),
+                        }),
+                    }));
+                }
+            },
+            AppEvent::OnboardingAuthComplete(result) => {
+                if let AppState::Onboarding { screen } = &mut self.app_state {
+                    screen.on_auth_complete(result);
+                }
+            }
+            AppEvent::OnboardingComplete(ChatWidgetArgs {
+                config,
+                enhanced_keys_supported,
+                initial_images,
+                initial_prompt,
+            }) => {
+                self.app_state = AppState::Chat {
+                    widget: Box::new(ChatWidget::new(
+                        config,
+                        self.server.clone(),
+                        self.app_event_tx.clone(),
+                        initial_prompt,
+                        initial_images,
+                        enhanced_keys_supported,
+                    )),
+                }
+            }
+            AppEvent::StartFileSearch(query) => {
+                if !query.is_empty() {
+                    self.file_search.on_user_query(query);
+                }
+            }
+            AppEvent::FileSearchResult { query, matches } => {
+                if let AppState::Chat { widget } = &mut self.app_state {
+                    widget.apply_file_search_result(query, matches);
+                }
+            }
+            AppEvent::UpdateReasoningEffort(effort) => {
+                if let AppState::Chat { widget } = &mut self.app_state {
+                    widget.set_reasoning_effort(effort);
+                }
+            }
+            AppEvent::UpdateModel(model) => {
+                if let AppState::Chat { widget } = &mut self.app_state {
+                    widget.set_model(model);
+                }
+            }
+            AppEvent::UpdateAskForApprovalPolicy(policy) => {
+                if let AppState::Chat { widget } = &mut self.app_state {
+                    widget.set_approval_policy(policy);
+                }
+            }
+            AppEvent::UpdateSandboxPolicy(policy) => {
+                if let AppState::Chat { widget } = &mut self.app_state {
+                    widget.set_sandbox_policy(policy);
+                }
             }
         }
-        terminal.clear()?;
-
-        Ok(())
+        Ok(true)
     }
 
     #[cfg(unix)]
diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs
index 901bb41024..c1427b3ff0 100644
--- a/codex-rs/tui/src/app_event_sender.rs
+++ b/codex-rs/tui/src/app_event_sender.rs
@@ -1,15 +1,15 @@
-use std::sync::mpsc::Sender;
+use tokio::sync::mpsc::UnboundedSender;
 
 use crate::app_event::AppEvent;
 use crate::session_log;
 
 #[derive(Clone, Debug)]
 pub(crate) struct AppEventSender {
-    pub app_event_tx: Sender<AppEvent>,
+    pub app_event_tx: UnboundedSender<AppEvent>,
 }
 
 impl AppEventSender {
-    pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
+    pub(crate) fn new(app_event_tx: UnboundedSender<AppEvent>) -> Self {
         Self { app_event_tx }
     }
 
diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
index b7e6e5e69a..1b23acb59d 100644
--- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
+++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
@@ -75,7 +75,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
 mod tests {
     use super::*;
     use crate::app_event::AppEvent;
-    use std::sync::mpsc::channel;
+    use tokio::sync::mpsc::unbounded_channel;
 
     fn make_exec_request() -> ApprovalRequest {
         ApprovalRequest::Exec {
@@ -87,15 +87,15 @@ mod tests {
 
     #[test]
     fn ctrl_c_aborts_and_clears_queue() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
-        let tx = AppEventSender::new(tx_raw);
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
+        let tx = AppEventSender::new(tx);
         let first = make_exec_request();
         let mut view = ApprovalModalView::new(first, tx);
         view.enqueue_request(make_exec_request());
 
-        let (tx_raw2, _rx2) = channel::<AppEvent>();
+        let (tx2, _rx2) = unbounded_channel::<AppEvent>();
         let mut pane = BottomPane::new(super::super::BottomPaneParams {
-            app_event_tx: AppEventSender::new(tx_raw2),
+            app_event_tx: AppEventSender::new(tx2),
             has_input_focus: true,
             enhanced_keys_supported: false,
             placeholder_text: "Ask Codex to do anything".to_string(),
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 5d555aa083..125b5e0209 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -745,6 +745,7 @@ mod tests {
     use crate::bottom_pane::InputResult;
     use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
     use crate::bottom_pane::textarea::TextArea;
+    use tokio::sync::mpsc::unbounded_channel;
 
     #[test]
     fn test_current_at_token_basic_cases() {
@@ -901,7 +902,7 @@ mod tests {
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
 
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -925,7 +926,7 @@ mod tests {
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
 
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -955,7 +956,7 @@ mod tests {
         use crossterm::event::KeyModifiers;
 
         let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -977,7 +978,7 @@ mod tests {
         use ratatui::Terminal;
         use ratatui::backend::TestBackend;
 
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
             Ok(t) => t,
@@ -1033,9 +1034,9 @@ mod tests {
         use crossterm::event::KeyCode;
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
-        use std::sync::mpsc::TryRecvError;
+        use tokio::sync::mpsc::error::TryRecvError;
 
-        let (tx, rx) = std::sync::mpsc::channel();
+        let (tx, mut rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1078,7 +1079,7 @@ mod tests {
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
 
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1099,9 +1100,9 @@ mod tests {
         use crossterm::event::KeyCode;
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
-        use std::sync::mpsc::TryRecvError;
+        use tokio::sync::mpsc::error::TryRecvError;
 
-        let (tx, rx) = std::sync::mpsc::channel();
+        let (tx, mut rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1141,7 +1142,7 @@ mod tests {
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
 
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1215,7 +1216,7 @@ mod tests {
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
 
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1282,7 +1283,7 @@ mod tests {
         use crossterm::event::KeyEvent;
         use crossterm::event::KeyModifiers;
 
-        let (tx, _rx) = std::sync::mpsc::channel();
+        let (tx, _rx) = unbounded_channel::<AppEvent>();
         let sender = AppEventSender::new(tx);
         let mut composer =
             ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs
index 04b745d1ff..87bcc438e9 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs
@@ -192,7 +192,7 @@ mod tests {
     use super::*;
     use crate::app_event::AppEvent;
     use codex_core::protocol::Op;
-    use std::sync::mpsc::channel;
+    use tokio::sync::mpsc::unbounded_channel;
 
     #[test]
     fn duplicate_submissions_are_not_recorded() {
@@ -219,7 +219,7 @@ mod tests {
 
     #[test]
     fn navigation_with_async_fetch() {
-        let (tx, rx) = channel::<AppEvent>();
+        let (tx, mut rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx);
 
         let mut history = ChatComposerHistory::new();
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index b27ea6e945..71fb0bbb9f 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -359,7 +359,7 @@ mod tests {
     use crate::app_event::AppEvent;
     use ratatui::buffer::Buffer;
     use ratatui::layout::Rect;
-    use std::sync::mpsc::channel;
+    use tokio::sync::mpsc::unbounded_channel;
 
     fn exec_request() -> ApprovalRequest {
         ApprovalRequest::Exec {
@@ -371,7 +371,7 @@ mod tests {
 
     #[test]
     fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut pane = BottomPane::new(BottomPaneParams {
             app_event_tx: tx,
@@ -389,7 +389,7 @@ mod tests {
 
     #[test]
     fn overlay_not_shown_above_approval_modal() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut pane = BottomPane::new(BottomPaneParams {
             app_event_tx: tx,
@@ -418,7 +418,7 @@ mod tests {
 
     #[test]
     fn composer_not_shown_after_denied_if_task_running() {
-        let (tx_raw, rx) = channel::<AppEvent>();
+        let (tx_raw, rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut pane = BottomPane::new(BottomPaneParams {
             app_event_tx: tx.clone(),
@@ -468,7 +468,7 @@ mod tests {
 
     #[test]
     fn status_indicator_visible_during_command_execution() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut pane = BottomPane::new(BottomPaneParams {
             app_event_tx: tx,
@@ -500,7 +500,7 @@ mod tests {
 
     #[test]
     fn bottom_padding_present_for_status_view() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut pane = BottomPane::new(BottomPaneParams {
             app_event_tx: tx,
@@ -552,7 +552,7 @@ mod tests {
 
     #[test]
     fn bottom_padding_shrinks_when_tiny() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut pane = BottomPane::new(BottomPaneParams {
             app_event_tx: tx,
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 3bb5d42f9f..82e7470c83 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -30,7 +30,6 @@ use std::io::BufRead;
 use std::io::BufReader;
 use std::io::Read;
 use std::path::PathBuf;
-use std::sync::mpsc::channel;
 use tokio::sync::mpsc::unbounded_channel;
 
 fn test_config() -> Config {
@@ -45,7 +44,7 @@ fn test_config() -> Config {
 
 #[test]
 fn final_answer_without_newline_is_flushed_immediately() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Set up a VT100 test terminal to capture ANSI visual output
     let width: u16 = 80;
@@ -73,7 +72,7 @@ fn final_answer_without_newline_is_flushed_immediately() {
     });
 
     // Drain history insertions and verify the final line is present.
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert!(
         cells.iter().any(|lines| {
             let s = lines
@@ -101,7 +100,7 @@ fn final_answer_without_newline_is_flushed_immediately() {
 
 #[tokio::test(flavor = "current_thread")]
 async fn helpers_are_available_and_do_not_panic() {
-    let (tx_raw, _rx) = channel::<AppEvent>();
+    let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
     let tx = AppEventSender::new(tx_raw);
     let cfg = test_config();
     let conversation_manager = Arc::new(ConversationManager::default());
@@ -113,10 +112,10 @@ async fn helpers_are_available_and_do_not_panic() {
 // --- Helpers for tests that need direct construction and event draining ---
 fn make_chatwidget_manual() -> (
     ChatWidget<'static>,
-    std::sync::mpsc::Receiver<AppEvent>,
+    tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
     tokio::sync::mpsc::UnboundedReceiver<Op>,
 ) {
-    let (tx_raw, rx) = channel::<AppEvent>();
+    let (tx_raw, rx) = unbounded_channel::<AppEvent>();
     let app_event_tx = AppEventSender::new(tx_raw);
     let (op_tx, op_rx) = unbounded_channel::<Op>();
     let cfg = test_config();
@@ -148,7 +147,7 @@ fn make_chatwidget_manual() -> (
 }
 
 fn drain_insert_history(
-    rx: &std::sync::mpsc::Receiver<AppEvent>,
+    rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
 ) -> Vec<Vec<ratatui::text::Line<'static>>> {
     let mut out = Vec::new();
     while let Ok(ev) = rx.try_recv() {
@@ -196,7 +195,7 @@ fn open_fixture(name: &str) -> std::fs::File {
 
 #[test]
 fn exec_history_cell_shows_working_then_completed() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Begin command
     chat.handle_codex_event(Event {
@@ -226,7 +225,7 @@ fn exec_history_cell_shows_working_then_completed() {
         }),
     });
 
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert_eq!(
         cells.len(),
         1,
@@ -241,7 +240,7 @@ fn exec_history_cell_shows_working_then_completed() {
 
 #[test]
 fn exec_history_cell_shows_working_then_failed() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Begin command
     chat.handle_codex_event(Event {
@@ -271,7 +270,7 @@ fn exec_history_cell_shows_working_then_failed() {
         }),
     });
 
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert_eq!(
         cells.len(),
         1,
@@ -286,7 +285,7 @@ fn exec_history_cell_shows_working_then_failed() {
 
 #[tokio::test(flavor = "current_thread")]
 async fn binary_size_transcript_matches_ideal_fixture() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Set up a VT100 test terminal to capture ANSI visual output
     let width: u16 = 80;
@@ -423,7 +422,7 @@ async fn binary_size_transcript_matches_ideal_fixture() {
 
 #[test]
 fn apply_patch_events_emit_history_cells() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // 1) Approval request -> proposed patch summary cell
     let mut changes = HashMap::new();
@@ -443,7 +442,7 @@ fn apply_patch_events_emit_history_cells() {
         id: "s1".into(),
         msg: EventMsg::ApplyPatchApprovalRequest(ev),
     });
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert!(!cells.is_empty(), "expected pending patch cell to be sent");
     let blob = lines_to_single_string(cells.last().unwrap());
     assert!(
@@ -468,7 +467,7 @@ fn apply_patch_events_emit_history_cells() {
         id: "s1".into(),
         msg: EventMsg::PatchApplyBegin(begin),
     });
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert!(!cells.is_empty(), "expected applying patch cell to be sent");
     let blob = lines_to_single_string(cells.last().unwrap());
     assert!(
@@ -487,7 +486,7 @@ fn apply_patch_events_emit_history_cells() {
         id: "s1".into(),
         msg: EventMsg::PatchApplyEnd(end),
     });
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert!(!cells.is_empty(), "expected applied patch cell to be sent");
     let blob = lines_to_single_string(cells.last().unwrap());
     assert!(
@@ -498,7 +497,7 @@ fn apply_patch_events_emit_history_cells() {
 
 #[test]
 fn apply_patch_approval_sends_op_with_submission_id() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
     // Simulate receiving an approval request with a distinct submission id and call id
     let mut changes = HashMap::new();
     changes.insert(
@@ -539,7 +538,7 @@ fn apply_patch_approval_sends_op_with_submission_id() {
 
 #[test]
 fn apply_patch_full_flow_integration_like() {
-    let (mut chat, rx, mut op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual();
 
     // 1) Backend requests approval
     let mut changes = HashMap::new();
@@ -655,7 +654,7 @@ fn apply_patch_untrusted_shows_approval_modal() {
 
 #[test]
 fn apply_patch_request_shows_diff_summary() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Ensure we are in OnRequest so an approval is surfaced
     chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
@@ -680,7 +679,7 @@ fn apply_patch_request_shows_diff_summary() {
     });
 
     // Drain history insertions and verify the diff summary is present
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert!(
         !cells.is_empty(),
         "expected a history cell with the proposed patch summary"
@@ -702,7 +701,7 @@ fn apply_patch_request_shows_diff_summary() {
 
 #[test]
 fn plan_update_renders_history_cell() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
     let update = UpdatePlanArgs {
         explanation: Some("Adapting plan".to_string()),
         plan: vec![
@@ -724,7 +723,7 @@ fn plan_update_renders_history_cell() {
         id: "sub-1".into(),
         msg: EventMsg::PlanUpdate(update),
     });
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     assert!(!cells.is_empty(), "expected plan update cell to be sent");
     let blob = lines_to_single_string(cells.last().unwrap());
     assert!(
@@ -738,7 +737,7 @@ fn plan_update_renders_history_cell() {
 
 #[test]
 fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Answer: no header until a newline commit
     chat.handle_codex_event(Event {
@@ -796,7 +795,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
     );
 
     // Reasoning: header immediately
-    let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual();
+    let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual();
     chat2.handle_codex_event(Event {
         id: "sub-b".into(),
         msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
@@ -826,7 +825,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
 
 #[test]
 fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Begin turn
     chat.handle_codex_event(Event {
@@ -858,7 +857,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
         }),
     });
 
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     let mut header_count = 0usize;
     let mut combined = String::new();
     for lines in &cells {
@@ -894,7 +893,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
 
 #[test]
 fn final_reasoning_then_message_without_deltas_are_rendered() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // No deltas; only final reasoning followed by final message.
     chat.handle_codex_event(Event {
@@ -911,7 +910,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() {
     });
 
     // Drain history and snapshot the combined visible content.
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     let combined = cells
         .iter()
         .map(|lines| lines_to_single_string(lines))
@@ -921,7 +920,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() {
 
 #[test]
 fn deltas_then_same_final_message_are_rendered_snapshot() {
-    let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
 
     // Stream some reasoning deltas first.
     chat.handle_codex_event(Event {
@@ -972,7 +971,7 @@ fn deltas_then_same_final_message_are_rendered_snapshot() {
 
     // Snapshot the combined visible content to ensure we render as expected
     // when deltas are followed by the identical final message.
-    let cells = drain_insert_history(&rx);
+    let cells = drain_insert_history(&mut rx);
     let combined = cells
         .iter()
         .map(|lines| lines_to_single_string(lines))
diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs
index ced667af8d..63826bbf82 100644
--- a/codex-rs/tui/src/insert_history.rs
+++ b/codex-rs/tui/src/insert_history.rs
@@ -38,7 +38,6 @@ pub fn insert_history_lines_to_writer<B, W>(
     W: Write,
 {
     let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
-    let cursor_pos = terminal.get_cursor_position().ok();
 
     let mut area = terminal.get_frame().area();
 
@@ -104,9 +103,14 @@ pub fn insert_history_lines_to_writer<B, W>(
     queue!(writer, ResetScrollRegion).ok();
 
     // Restore the cursor position to where it was before we started.
-    if let Some(cursor_pos) = cursor_pos {
-        queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
-    }
+    queue!(
+        writer,
+        MoveTo(
+            terminal.last_known_cursor_pos.x,
+            terminal.last_known_cursor_pos.y
+        )
+    )
+    .ok();
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 0f8b2242cb..76487ef366 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -247,10 +247,11 @@ pub async fn run_main(
     }
 
     run_ratatui_app(cli, config, should_show_trust_screen)
+        .await
         .map_err(|err| std::io::Error::other(err.to_string()))
 }
 
-fn run_ratatui_app(
+async fn run_ratatui_app(
     cli: Cli,
     config: Config,
     should_show_trust_screen: bool,
@@ -275,7 +276,7 @@ fn run_ratatui_app(
     let Cli { prompt, images, .. } = cli;
     let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
 
-    let app_result = app.run(&mut terminal);
+    let app_result = app.run(&mut terminal).await;
     let usage = app.token_usage();
 
     restore();
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
index f63fc836a6..70dd2ed0b0 100644
--- a/codex-rs/tui/src/status_indicator_widget.rs
+++ b/codex-rs/tui/src/status_indicator_widget.rs
@@ -213,11 +213,11 @@ mod tests {
     use super::*;
     use crate::app_event::AppEvent;
     use crate::app_event_sender::AppEventSender;
-    use std::sync::mpsc::channel;
+    use tokio::sync::mpsc::unbounded_channel;
 
     #[test]
     fn renders_without_left_border_or_padding() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut w = StatusIndicatorWidget::new(tx);
         w.restart_with_text("Hello".to_string());
@@ -235,7 +235,7 @@ mod tests {
 
     #[test]
     fn working_header_is_present_on_last_line() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut w = StatusIndicatorWidget::new(tx);
         w.restart_with_text("Hi".to_string());
@@ -256,7 +256,7 @@ mod tests {
 
     #[test]
     fn header_starts_at_expected_position() {
-        let (tx_raw, _rx) = channel::<AppEvent>();
+        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let mut w = StatusIndicatorWidget::new(tx);
         w.restart_with_text("Hello".to_string());
diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs
index c2d7f70af0..d317ff0d8a 100644
--- a/codex-rs/tui/src/user_approval_widget.rs
+++ b/codex-rs/tui/src/user_approval_widget.rs
@@ -424,11 +424,11 @@ mod tests {
     use crossterm::event::KeyCode;
     use crossterm::event::KeyEvent;
     use crossterm::event::KeyModifiers;
-    use std::sync::mpsc::channel;
+    use tokio::sync::mpsc::unbounded_channel;
 
     #[test]
     fn lowercase_shortcut_is_accepted() {
-        let (tx_raw, rx) = channel::<AppEvent>();
+        let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let req = ApprovalRequest::Exec {
             id: "1".to_string(),
@@ -438,7 +438,10 @@ mod tests {
         let mut widget = UserApprovalWidget::new(req, tx);
         widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
         assert!(widget.is_complete());
-        let events: Vec<AppEvent> = rx.try_iter().collect();
+        let mut events: Vec<AppEvent> = Vec::new();
+        while let Ok(ev) = rx.try_recv() {
+            events.push(ev);
+        }
         assert!(events.iter().any(|e| matches!(
             e,
             AppEvent::CodexOp(Op::ExecApproval {
@@ -450,7 +453,7 @@ mod tests {
 
     #[test]
     fn uppercase_shortcut_is_accepted() {
-        let (tx_raw, rx) = channel::<AppEvent>();
+        let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
         let tx = AppEventSender::new(tx_raw);
         let req = ApprovalRequest::Exec {
             id: "2".to_string(),
@@ -460,7 +463,10 @@ mod tests {
         let mut widget = UserApprovalWidget::new(req, tx);
         widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE));
         assert!(widget.is_complete());
-        let events: Vec<AppEvent> = rx.try_iter().collect();
+        let mut events: Vec<AppEvent> = Vec::new();
+        while let Ok(ev) = rx.try_recv() {
+            events.push(ev);
+        }
         assert!(events.iter().any(|e| matches!(
             e,
             AppEvent::CodexOp(Op::ExecApproval {

Review Comments

codex-rs/tui/src/insert_history.rs

@@ -104,9 +103,14 @@ pub fn insert_history_lines_to_writer<B, W>(
     queue!(writer, ResetScrollRegion).ok();
 
     // Restore the cursor position to where it was before we started.
-    if let Some(cursor_pos) = cursor_pos {
-        queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
-    }
+    queue!(

Is this related to the crossterm refactoring? It seems like no?

@@ -104,9 +103,14 @@ pub fn insert_history_lines_to_writer<B, W>(
     queue!(writer, ResetScrollRegion).ok();
 
     // Restore the cursor position to where it was before we started.
-    if let Some(cursor_pos) = cursor_pos {
-        queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
-    }
+    queue!(

Cool, thanks for explaining!