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

1447 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #2489: tui: switch to using tokio + EventStream for processing crossterm events
- URL: https://github.com/openai/codex/pull/2489
- Author: nornagon-openai
- Created: 2025-08-20 03:19:49 UTC
- Updated: 2025-08-20 17:18:55 UTC
- Changes: +387/-381, Files changed: 13, Commits: 7
## Description
bringing the tui more into tokio-land to make it easier to factorize.
fyi @bolinfest
## Full Diff
```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
- Created: 2025-08-20 17:12:17 UTC | Link: https://github.com/openai/codex/pull/2489#discussion_r2288811659
```diff
@@ -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?
- Created: 2025-08-20 17:18:54 UTC | Link: https://github.com/openai/codex/pull/2489#discussion_r2288825099
```diff
@@ -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!