mirror of
https://github.com/openai/codex.git
synced 2026-04-28 16:45:54 +00:00
1447 lines
61 KiB
Markdown
1447 lines
61 KiB
Markdown
# 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! |