diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dc9385d9..d879d196 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 9e0e31e1..b5ec8d04 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 c7a16936..6ce51e5d 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, app_event_tx: AppEventSender, - app_event_rx: Receiver, + app_event_rx: UnboundedReceiver, 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::(); + let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); { 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); - } - }); + 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)) + } + crossterm::event::Event::Resize(_, _) => { + Some(AppEvent::Redraw) + } + 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 any other events. + None + } } + }, + } + } && self.handle_event(terminal, event)? + {} + terminal.clear()?; + Ok(()) + } + + fn handle_event(&mut self, terminal: &mut tui::Tui, event: AppEvent) -> Result { + 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::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::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. } - } - 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 { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + match &mut self.app_state { AppState::Chat { widget } => { - widget.on_ctrl_c(); + 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); } - }, - KeyEvent { - code: KeyCode::Char('z'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - #[cfg(unix)] - { - self.suspend(terminal)?; - } - // No-op on non-Unix platforms. } - 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); + } + KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.dispatch_key_event(key_event); + } + _ => { + // Ignore Release key events. + } + }; + } + 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()); + } + } + SlashCommand::Compact => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + } + } + SlashCommand::Model => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.open_model_popup(); + } + } + SlashCommand::Approvals => { + if let AppState::Chat { widget } = &mut self.app_state { + 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); + } + 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 { - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.dispatch_key_event(key_event); - } - _ => { - // Ignore Release key events. - } - }; + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); } - 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) => { + SlashCommand::Mention => { if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_output(text); + widget.insert_str("@"); } } - 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::Status => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_status_output(); } - 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::Mcp => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_mcp_output(); } - SlashCommand::Compact => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); - } - } - SlashCommand::Model => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_model_popup(); - } - } - SlashCommand::Approvals => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_approvals_popup(); - } - } - SlashCommand::Quit => { - break; - } - SlashCommand::Logout => { - if let Err(e) = codex_login::logout(&self.config.codex_home) { - tracing::error!("failed to logout: {e}"); - } - break; - } - SlashCommand::Diff => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_in_progress(); - } + } + #[cfg(debug_assertions)] + SlashCommand::TestApproval => { + use codex_core::protocol::EventMsg; + use std::collections::HashMap; - 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)); - }); - } - 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(); - } - } - SlashCommand::Mcp => { - if let AppState::Chat { widget } = &mut self.app_state { - 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; - 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); - } + 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::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::OnboardingAuthComplete(result) => { + if let AppState::Onboarding { screen } = &mut self.app_state { + screen.on_auth_complete(result); } - AppEvent::StartFileSearch(query) => { - if !query.is_empty() { - self.file_search.on_user_query(query); - } + } + 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::FileSearchResult { query, matches } => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.apply_file_search_result(query, matches); - } + } + AppEvent::StartFileSearch(query) => { + if !query.is_empty() { + self.file_search.on_user_query(query); } - AppEvent::UpdateReasoningEffort(effort) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_reasoning_effort(effort); - } + } + AppEvent::FileSearchResult { query, matches } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.apply_file_search_result(query, matches); } - AppEvent::UpdateModel(model) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_model(model); - } + } + AppEvent::UpdateReasoningEffort(effort) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_reasoning_effort(effort); } - AppEvent::UpdateAskForApprovalPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_approval_policy(policy); - } + } + AppEvent::UpdateModel(model) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_model(model); } - AppEvent::UpdateSandboxPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_sandbox_policy(policy); - } + } + 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 901bb410..c1427b3f 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, + pub app_event_tx: UnboundedSender, } impl AppEventSender { - pub(crate) fn new(app_event_tx: Sender) -> Self { + pub(crate) fn new(app_event_tx: UnboundedSender) -> 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 b7e6e5e6..1b23acb5 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::(); - let tx = AppEventSender::new(tx_raw); + let (tx, _rx) = unbounded_channel::(); + 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::(); + let (tx2, _rx2) = unbounded_channel::(); 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 29b03c89..675d2292 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -750,6 +750,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() { @@ -906,7 +907,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -930,7 +931,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -960,7 +961,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::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -982,7 +983,7 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { Ok(t) => t, @@ -1038,9 +1039,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::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1083,7 +1084,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1104,9 +1105,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::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1146,7 +1147,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1220,7 +1221,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1287,7 +1288,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); 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 04b745d1..87bcc438 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::(); + let (tx, mut rx) = unbounded_channel::(); 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 b27ea6e9..71fb0bbb 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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::(); + let (tx_raw, rx) = unbounded_channel::(); 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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 3bb5d42f..82e7470c 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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, + tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { - let (tx_raw, rx) = channel::(); + let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let cfg = test_config(); @@ -148,7 +147,7 @@ fn make_chatwidget_manual() -> ( } fn drain_insert_history( - rx: &std::sync::mpsc::Receiver, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec>> { 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 ced667af..63826bbf 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( 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( 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 0f8b2242..76487ef3 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 f63fc836..70dd2ed0 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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::(); + let (tx_raw, _rx) = unbounded_channel::(); 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 c2d7f70a..d317ff0d 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::(); + let (tx_raw, mut rx) = unbounded_channel::(); 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 = rx.try_iter().collect(); + let mut events: Vec = 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::(); + let (tx_raw, mut rx) = unbounded_channel::(); 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 = rx.try_iter().collect(); + let mut events: Vec = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } assert!(events.iter().any(|e| matches!( e, AppEvent::CodexOp(Op::ExecApproval {