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

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

fyi @bolinfest
This commit is contained in:
Jeremy Rose
2025-08-20 10:11:09 -07:00
committed by GitHub
parent 8481eb4c6e
commit 61bbabe7d9
13 changed files with 396 additions and 390 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -961,6 +961,7 @@ dependencies = [
"supports-color", "supports-color",
"textwrap 0.16.2", "textwrap 0.16.2",
"tokio", "tokio",
"tokio-stream",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
@@ -1161,6 +1162,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"crossterm_winapi", "crossterm_winapi",
"futures-core",
"mio", "mio",
"parking_lot", "parking_lot",
"rustix 0.38.44", "rustix 0.38.44",

View File

@@ -38,7 +38,7 @@ codex-login = { path = "../login" }
codex-ollama = { path = "../ollama" } codex-ollama = { path = "../ollama" }
codex-protocol = { path = "../protocol" } codex-protocol = { path = "../protocol" }
color-eyre = "0.6.3" 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" diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] } image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
lazy_static = "1" lazy_static = "1"
@@ -68,6 +68,7 @@ tokio = { version = "1", features = [
"rt-multi-thread", "rt-multi-thread",
"signal", "signal",
] } ] }
tokio-stream = "0.1.17"
tracing = { version = "0.1.41", features = ["log"] } tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3" tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@@ -27,11 +27,12 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::mpsc::Receiver;
use std::sync::mpsc::channel;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use std::time::Instant; use std::time::Instant;
use tokio::select;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
/// Time window for debouncing redraw requests. /// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1); const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
@@ -53,7 +54,7 @@ enum AppState<'a> {
pub(crate) struct App<'a> { pub(crate) struct App<'a> {
server: Arc<ConversationManager>, server: Arc<ConversationManager>,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
app_event_rx: Receiver<AppEvent>, app_event_rx: UnboundedReceiver<AppEvent>,
app_state: AppState<'a>, app_state: AppState<'a>,
/// Config is stored here so we can recreate ChatWidgets as needed. /// Config is stored here so we can recreate ChatWidgets as needed.
@@ -92,52 +93,11 @@ impl App<'_> {
) -> Self { ) -> Self {
let conversation_manager = Arc::new(ConversationManager::default()); 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 app_event_tx = AppEventSender::new(app_event_tx);
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); 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 login_status = get_login_status(&config);
let should_show_onboarding = let should_show_onboarding =
should_show_onboarding(login_status, &config, show_trust_screen); 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 // Spawn a single scheduler thread that coalesces both debounced redraw
// requests and animation frame requests, and emits a single Redraw event // requests and animation frame requests, and emits a single Redraw event
// at the earliest requested time. // 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(); let app_event_tx = app_event_tx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -234,306 +194,338 @@ impl App<'_> {
let _ = self.frame_schedule_tx.send(Instant::now() + dur); let _ = self.frame_schedule_tx.send(Instant::now() + dur);
} }
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { pub(crate) async fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Schedule the first render immediately. use tokio_stream::StreamExt;
let _ = self.frame_schedule_tx.send(Instant::now());
while let Ok(event) = self.app_event_rx.recv() { self.handle_event(terminal, AppEvent::Redraw)?;
match event {
AppEvent::InsertHistory(lines) => { let mut crossterm_events = crossterm::event::EventStream::new();
self.pending_history_lines.extend(lines);
self.app_event_tx.send(AppEvent::RequestRedraw); while let Some(event) = {
} select! {
AppEvent::RequestRedraw => { maybe_app_event = self.app_event_rx.recv() => {
self.schedule_frame_in(REDRAW_DEBOUNCE); maybe_app_event
} },
AppEvent::ScheduleFrameIn(dur) => { Some(Ok(event)) = crossterm_events.next() => {
self.schedule_frame_in(dur); match event {
} crossterm::event::Event::Key(key_event) => {
AppEvent::Redraw => { Some(AppEvent::KeyEvent(key_event))
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; }
} crossterm::event::Event::Resize(_, _) => {
AppEvent::StartCommitAnimation => { Some(AppEvent::Redraw)
if self }
.commit_anim_running crossterm::event::Event::Paste(pasted) => {
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) // Many terminals convert newlines to \r when pasting (e.g., iTerm2),
.is_ok() // but tui-textarea expects \n. Normalize CR to LF.
{ // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
let tx = self.app_event_tx.clone(); // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let running = self.commit_anim_running.clone(); let pasted = pasted.replace("\r", "\n");
thread::spawn(move || { Some(AppEvent::Paste(pasted))
while running.load(Ordering::Relaxed) { }
thread::sleep(Duration::from_millis(50)); _ => {
tx.send(AppEvent::CommitTick); // Ignore any other events.
} None
}); }
} }
},
}
} && self.handle_event(terminal, event)?
{}
terminal.clear()?;
Ok(())
}
fn handle_event(&mut self, terminal: &mut tui::Tui, event: AppEvent) -> Result<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);
}
});
} }
AppEvent::StopCommitAnimation => { }
self.commit_anim_running.store(false, Ordering::Release); AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_commit_tick();
} }
AppEvent::CommitTick => { }
if let AppState::Chat { widget } = &mut self.app_state { AppEvent::KeyEvent(key_event) => {
widget.on_commit_tick(); 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.
} }
} KeyEvent {
AppEvent::KeyEvent(key_event) => { code: KeyCode::Char('d'),
match key_event { modifiers: crossterm::event::KeyModifiers::CONTROL,
KeyEvent { kind: KeyEventKind::Press,
code: KeyCode::Char('c'), ..
modifiers: crossterm::event::KeyModifiers::CONTROL, } => {
kind: KeyEventKind::Press, match &mut self.app_state {
..
} => match &mut self.app_state {
AppState::Chat { widget } => { AppState::Chat { widget } => {
widget.on_ctrl_c(); if widget.composer_is_empty() {
self.app_event_tx.send(AppEvent::ExitRequest);
} else {
// Treat Ctrl+D as a normal key event when the composer
// is not empty so that it doesn't quit the application
// prematurely.
self.dispatch_key_event(key_event);
}
} }
AppState::Onboarding { .. } => { AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest); self.app_event_tx.send(AppEvent::ExitRequest);
} }
},
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
#[cfg(unix)]
{
self.suspend(terminal)?;
}
// No-op on non-Unix platforms.
} }
KeyEvent { }
code: KeyCode::Char('d'), KeyEvent {
modifiers: crossterm::event::KeyModifiers::CONTROL, kind: KeyEventKind::Press | KeyEventKind::Repeat,
kind: KeyEventKind::Press, ..
.. } => {
} => { self.dispatch_key_event(key_event);
match &mut self.app_state { }
AppState::Chat { widget } => { _ => {
if widget.composer_is_empty() { // Ignore Release key events.
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 AppEvent::Paste(text) => {
// prematurely. self.dispatch_paste_event(text);
self.dispatch_key_event(key_event); }
} AppEvent::CodexEvent(event) => {
} self.dispatch_codex_event(event);
AppState::Onboarding { .. } => { }
self.app_event_tx.send(AppEvent::ExitRequest); AppEvent::ExitRequest => {
return Ok(false);
}
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::DiffResult(text) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(text);
}
}
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
// User accepted switch to chat view.
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.server.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
SlashCommand::Init => {
// Guard: do not run if a task is active.
if let AppState::Chat { widget } = &mut self.app_state {
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
widget.submit_text_message(INIT_PROMPT.to_string());
}
}
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
SlashCommand::Model => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.open_model_popup();
}
}
SlashCommand::Approvals => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.open_approvals_popup();
}
}
SlashCommand::Quit => {
return Ok(false);
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(&self.config.codex_home) {
tracing::error!("failed to logout: {e}");
}
return Ok(false);
}
SlashCommand::Diff => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_in_progress();
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let text = match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
} }
} }
} Err(e) => format!("Failed to compute diff: {e}"),
KeyEvent { };
kind: KeyEventKind::Press | KeyEventKind::Repeat, tx.send(AppEvent::DiffResult(text));
.. });
} => {
self.dispatch_key_event(key_event);
}
_ => {
// Ignore Release key events.
}
};
} }
AppEvent::Paste(text) => { SlashCommand::Mention => {
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 { if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(text); widget.insert_str("@");
} }
} }
AppEvent::DispatchCommand(command) => match command { SlashCommand::Status => {
SlashCommand::New => { if let AppState::Chat { widget } = &mut self.app_state {
// User accepted switch to chat view. widget.add_status_output();
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. SlashCommand::Mcp => {
if let AppState::Chat { widget } = &mut self.app_state { if let AppState::Chat { widget } = &mut self.app_state {
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); widget.add_mcp_output();
widget.submit_text_message(INIT_PROMPT.to_string());
}
} }
SlashCommand::Compact => { }
if let AppState::Chat { widget } = &mut self.app_state { #[cfg(debug_assertions)]
widget.clear_token_usage(); SlashCommand::TestApproval => {
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); use codex_core::protocol::EventMsg;
} use std::collections::HashMap;
}
SlashCommand::Model => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.open_model_popup();
}
}
SlashCommand::Approvals => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.open_approvals_popup();
}
}
SlashCommand::Quit => {
break;
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(&self.config.codex_home) {
tracing::error!("failed to logout: {e}");
}
break;
}
SlashCommand::Diff => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_in_progress();
}
let tx = self.app_event_tx.clone(); use codex_core::protocol::ApplyPatchApprovalRequestEvent;
tokio::spawn(async move { use codex_core::protocol::FileChange;
let text = match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
}
}
Err(e) => format!("Failed to compute diff: {e}"),
};
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::Mention => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str("@");
}
}
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
}
}
SlashCommand::Mcp => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_mcp_output();
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use codex_core::protocol::EventMsg;
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent; self.app_event_tx.send(AppEvent::CodexEvent(Event {
use codex_core::protocol::FileChange; id: "1".to_string(),
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
self.app_event_tx.send(AppEvent::CodexEvent(Event { // call_id: "1".to_string(),
id: "1".to_string(), // command: vec!["git".into(), "apply".into()],
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { // cwd: self.config.cwd.clone(),
// call_id: "1".to_string(), // reason: Some("test".to_string()),
// command: vec!["git".into(), "apply".into()], // }),
// cwd: self.config.cwd.clone(), msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
// reason: Some("test".to_string()), call_id: "1".to_string(),
// }), changes: HashMap::from([
msg: EventMsg::ApplyPatchApprovalRequest( (
ApplyPatchApprovalRequestEvent { PathBuf::from("/tmp/test.txt"),
call_id: "1".to_string(), FileChange::Add {
changes: HashMap::from([ content: "test".to_string(),
( },
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,
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")),
), }),
]), }));
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, AppEvent::OnboardingAuthComplete(result) => {
enhanced_keys_supported, if let AppState::Onboarding { screen } = &mut self.app_state {
initial_images, screen.on_auth_complete(result);
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() { AppEvent::OnboardingComplete(ChatWidgetArgs {
self.file_search.on_user_query(query); config,
} enhanced_keys_supported,
initial_images,
initial_prompt,
}) => {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
config,
self.server.clone(),
self.app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
)),
} }
AppEvent::FileSearchResult { query, matches } => { }
if let AppState::Chat { widget } = &mut self.app_state { AppEvent::StartFileSearch(query) => {
widget.apply_file_search_result(query, matches); if !query.is_empty() {
} self.file_search.on_user_query(query);
} }
AppEvent::UpdateReasoningEffort(effort) => { }
if let AppState::Chat { widget } = &mut self.app_state { AppEvent::FileSearchResult { query, matches } => {
widget.set_reasoning_effort(effort); if let AppState::Chat { widget } = &mut self.app_state {
} widget.apply_file_search_result(query, matches);
} }
AppEvent::UpdateModel(model) => { }
if let AppState::Chat { widget } = &mut self.app_state { AppEvent::UpdateReasoningEffort(effort) => {
widget.set_model(model); if let AppState::Chat { widget } = &mut self.app_state {
} widget.set_reasoning_effort(effort);
} }
AppEvent::UpdateAskForApprovalPolicy(policy) => { }
if let AppState::Chat { widget } = &mut self.app_state { AppEvent::UpdateModel(model) => {
widget.set_approval_policy(policy); if let AppState::Chat { widget } = &mut self.app_state {
} widget.set_model(model);
} }
AppEvent::UpdateSandboxPolicy(policy) => { }
if let AppState::Chat { widget } = &mut self.app_state { AppEvent::UpdateAskForApprovalPolicy(policy) => {
widget.set_sandbox_policy(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(true)
Ok(())
} }
#[cfg(unix)] #[cfg(unix)]

View File

@@ -1,15 +1,15 @@
use std::sync::mpsc::Sender; use tokio::sync::mpsc::UnboundedSender;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::session_log; use crate::session_log;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct AppEventSender { pub(crate) struct AppEventSender {
pub app_event_tx: Sender<AppEvent>, pub app_event_tx: UnboundedSender<AppEvent>,
} }
impl AppEventSender { 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 } Self { app_event_tx }
} }

View File

@@ -75,7 +75,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
mod tests { mod tests {
use super::*; use super::*;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use std::sync::mpsc::channel; use tokio::sync::mpsc::unbounded_channel;
fn make_exec_request() -> ApprovalRequest { fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec { ApprovalRequest::Exec {
@@ -87,15 +87,15 @@ mod tests {
#[test] #[test]
fn ctrl_c_aborts_and_clears_queue() { fn ctrl_c_aborts_and_clears_queue() {
let (tx_raw, _rx) = channel::<AppEvent>(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx);
let first = make_exec_request(); let first = make_exec_request();
let mut view = ApprovalModalView::new(first, tx); let mut view = ApprovalModalView::new(first, tx);
view.enqueue_request(make_exec_request()); 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 { 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, has_input_focus: true,
enhanced_keys_supported: false, enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(), placeholder_text: "Ask Codex to do anything".to_string(),

View File

@@ -750,6 +750,7 @@ mod tests {
use crate::bottom_pane::InputResult; use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
#[test] #[test]
fn test_current_at_token_basic_cases() { fn test_current_at_token_basic_cases() {
@@ -906,7 +907,7 @@ mod tests {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -930,7 +931,7 @@ mod tests {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -960,7 +961,7 @@ mod tests {
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); 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 sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -982,7 +983,7 @@ mod tests {
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
Ok(t) => t, Ok(t) => t,
@@ -1038,9 +1039,9 @@ mod tests {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; 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 sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1083,7 +1084,7 @@ mod tests {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1104,9 +1105,9 @@ mod tests {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; 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 sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1146,7 +1147,7 @@ mod tests {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1220,7 +1221,7 @@ mod tests {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
@@ -1287,7 +1288,7 @@ mod tests {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut composer = let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());

View File

@@ -192,7 +192,7 @@ mod tests {
use super::*; use super::*;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use codex_core::protocol::Op; use codex_core::protocol::Op;
use std::sync::mpsc::channel; use tokio::sync::mpsc::unbounded_channel;
#[test] #[test]
fn duplicate_submissions_are_not_recorded() { fn duplicate_submissions_are_not_recorded() {
@@ -219,7 +219,7 @@ mod tests {
#[test] #[test]
fn navigation_with_async_fetch() { fn navigation_with_async_fetch() {
let (tx, rx) = channel::<AppEvent>(); let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new(); let mut history = ChatComposerHistory::new();

View File

@@ -359,7 +359,7 @@ mod tests {
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use std::sync::mpsc::channel; use tokio::sync::mpsc::unbounded_channel;
fn exec_request() -> ApprovalRequest { fn exec_request() -> ApprovalRequest {
ApprovalRequest::Exec { ApprovalRequest::Exec {
@@ -371,7 +371,7 @@ mod tests {
#[test] #[test]
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { 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 tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
@@ -389,7 +389,7 @@ mod tests {
#[test] #[test]
fn overlay_not_shown_above_approval_modal() { 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 tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
@@ -418,7 +418,7 @@ mod tests {
#[test] #[test]
fn composer_not_shown_after_denied_if_task_running() { 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 tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx.clone(), app_event_tx: tx.clone(),
@@ -468,7 +468,7 @@ mod tests {
#[test] #[test]
fn status_indicator_visible_during_command_execution() { 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 tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
@@ -500,7 +500,7 @@ mod tests {
#[test] #[test]
fn bottom_padding_present_for_status_view() { 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 tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
@@ -552,7 +552,7 @@ mod tests {
#[test] #[test]
fn bottom_padding_shrinks_when_tiny() { 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 tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,

View File

@@ -30,7 +30,6 @@ use std::io::BufRead;
use std::io::BufReader; use std::io::BufReader;
use std::io::Read; use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc::channel;
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
fn test_config() -> Config { fn test_config() -> Config {
@@ -45,7 +44,7 @@ fn test_config() -> Config {
#[test] #[test]
fn final_answer_without_newline_is_flushed_immediately() { 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 // Set up a VT100 test terminal to capture ANSI visual output
let width: u16 = 80; 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. // Drain history insertions and verify the final line is present.
let cells = drain_insert_history(&rx); let cells = drain_insert_history(&mut rx);
assert!( assert!(
cells.iter().any(|lines| { cells.iter().any(|lines| {
let s = lines let s = lines
@@ -101,7 +100,7 @@ fn final_answer_without_newline_is_flushed_immediately() {
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn helpers_are_available_and_do_not_panic() { 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 tx = AppEventSender::new(tx_raw);
let cfg = test_config(); let cfg = test_config();
let conversation_manager = Arc::new(ConversationManager::default()); 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 --- // --- Helpers for tests that need direct construction and event draining ---
fn make_chatwidget_manual() -> ( fn make_chatwidget_manual() -> (
ChatWidget<'static>, ChatWidget<'static>,
std::sync::mpsc::Receiver<AppEvent>, tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>, 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 app_event_tx = AppEventSender::new(tx_raw);
let (op_tx, op_rx) = unbounded_channel::<Op>(); let (op_tx, op_rx) = unbounded_channel::<Op>();
let cfg = test_config(); let cfg = test_config();
@@ -148,7 +147,7 @@ fn make_chatwidget_manual() -> (
} }
fn drain_insert_history( fn drain_insert_history(
rx: &std::sync::mpsc::Receiver<AppEvent>, rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> Vec<Vec<ratatui::text::Line<'static>>> { ) -> Vec<Vec<ratatui::text::Line<'static>>> {
let mut out = Vec::new(); let mut out = Vec::new();
while let Ok(ev) = rx.try_recv() { while let Ok(ev) = rx.try_recv() {
@@ -196,7 +195,7 @@ fn open_fixture(name: &str) -> std::fs::File {
#[test] #[test]
fn exec_history_cell_shows_working_then_completed() { 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 // Begin command
chat.handle_codex_event(Event { 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!( assert_eq!(
cells.len(), cells.len(),
1, 1,
@@ -241,7 +240,7 @@ fn exec_history_cell_shows_working_then_completed() {
#[test] #[test]
fn exec_history_cell_shows_working_then_failed() { 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 // Begin command
chat.handle_codex_event(Event { 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!( assert_eq!(
cells.len(), cells.len(),
1, 1,
@@ -286,7 +285,7 @@ fn exec_history_cell_shows_working_then_failed() {
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn binary_size_transcript_matches_ideal_fixture() { 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 // Set up a VT100 test terminal to capture ANSI visual output
let width: u16 = 80; let width: u16 = 80;
@@ -423,7 +422,7 @@ async fn binary_size_transcript_matches_ideal_fixture() {
#[test] #[test]
fn apply_patch_events_emit_history_cells() { 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 // 1) Approval request -> proposed patch summary cell
let mut changes = HashMap::new(); let mut changes = HashMap::new();
@@ -443,7 +442,7 @@ fn apply_patch_events_emit_history_cells() {
id: "s1".into(), id: "s1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ev), 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"); assert!(!cells.is_empty(), "expected pending patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap()); let blob = lines_to_single_string(cells.last().unwrap());
assert!( assert!(
@@ -468,7 +467,7 @@ fn apply_patch_events_emit_history_cells() {
id: "s1".into(), id: "s1".into(),
msg: EventMsg::PatchApplyBegin(begin), 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"); assert!(!cells.is_empty(), "expected applying patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap()); let blob = lines_to_single_string(cells.last().unwrap());
assert!( assert!(
@@ -487,7 +486,7 @@ fn apply_patch_events_emit_history_cells() {
id: "s1".into(), id: "s1".into(),
msg: EventMsg::PatchApplyEnd(end), 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"); assert!(!cells.is_empty(), "expected applied patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap()); let blob = lines_to_single_string(cells.last().unwrap());
assert!( assert!(
@@ -498,7 +497,7 @@ fn apply_patch_events_emit_history_cells() {
#[test] #[test]
fn apply_patch_approval_sends_op_with_submission_id() { 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 // Simulate receiving an approval request with a distinct submission id and call id
let mut changes = HashMap::new(); let mut changes = HashMap::new();
changes.insert( changes.insert(
@@ -539,7 +538,7 @@ fn apply_patch_approval_sends_op_with_submission_id() {
#[test] #[test]
fn apply_patch_full_flow_integration_like() { 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 // 1) Backend requests approval
let mut changes = HashMap::new(); let mut changes = HashMap::new();
@@ -655,7 +654,7 @@ fn apply_patch_untrusted_shows_approval_modal() {
#[test] #[test]
fn apply_patch_request_shows_diff_summary() { 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 // Ensure we are in OnRequest so an approval is surfaced
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest; 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 // Drain history insertions and verify the diff summary is present
let cells = drain_insert_history(&rx); let cells = drain_insert_history(&mut rx);
assert!( assert!(
!cells.is_empty(), !cells.is_empty(),
"expected a history cell with the proposed patch summary" "expected a history cell with the proposed patch summary"
@@ -702,7 +701,7 @@ fn apply_patch_request_shows_diff_summary() {
#[test] #[test]
fn plan_update_renders_history_cell() { 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 { let update = UpdatePlanArgs {
explanation: Some("Adapting plan".to_string()), explanation: Some("Adapting plan".to_string()),
plan: vec![ plan: vec![
@@ -724,7 +723,7 @@ fn plan_update_renders_history_cell() {
id: "sub-1".into(), id: "sub-1".into(),
msg: EventMsg::PlanUpdate(update), 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"); assert!(!cells.is_empty(), "expected plan update cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap()); let blob = lines_to_single_string(cells.last().unwrap());
assert!( assert!(
@@ -738,7 +737,7 @@ fn plan_update_renders_history_cell() {
#[test] #[test]
fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { 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 // Answer: no header until a newline commit
chat.handle_codex_event(Event { chat.handle_codex_event(Event {
@@ -796,7 +795,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
); );
// Reasoning: header immediately // 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 { chat2.handle_codex_event(Event {
id: "sub-b".into(), id: "sub-b".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
@@ -826,7 +825,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
#[test] #[test]
fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { 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 // Begin turn
chat.handle_codex_event(Event { 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 header_count = 0usize;
let mut combined = String::new(); let mut combined = String::new();
for lines in &cells { for lines in &cells {
@@ -894,7 +893,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
#[test] #[test]
fn final_reasoning_then_message_without_deltas_are_rendered() { 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. // No deltas; only final reasoning followed by final message.
chat.handle_codex_event(Event { 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. // Drain history and snapshot the combined visible content.
let cells = drain_insert_history(&rx); let cells = drain_insert_history(&mut rx);
let combined = cells let combined = cells
.iter() .iter()
.map(|lines| lines_to_single_string(lines)) .map(|lines| lines_to_single_string(lines))
@@ -921,7 +920,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() {
#[test] #[test]
fn deltas_then_same_final_message_are_rendered_snapshot() { 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. // Stream some reasoning deltas first.
chat.handle_codex_event(Event { 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 // Snapshot the combined visible content to ensure we render as expected
// when deltas are followed by the identical final message. // 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 let combined = cells
.iter() .iter()
.map(|lines| lines_to_single_string(lines)) .map(|lines| lines_to_single_string(lines))

View File

@@ -38,7 +38,6 @@ pub fn insert_history_lines_to_writer<B, W>(
W: Write, W: Write,
{ {
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); 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(); let mut area = terminal.get_frame().area();
@@ -104,9 +103,14 @@ pub fn insert_history_lines_to_writer<B, W>(
queue!(writer, ResetScrollRegion).ok(); queue!(writer, ResetScrollRegion).ok();
// Restore the cursor position to where it was before we started. // Restore the cursor position to where it was before we started.
if let Some(cursor_pos) = cursor_pos { queue!(
queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok(); writer,
} MoveTo(
terminal.last_known_cursor_pos.x,
terminal.last_known_cursor_pos.y
)
)
.ok();
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]

View File

@@ -247,10 +247,11 @@ pub async fn run_main(
} }
run_ratatui_app(cli, config, should_show_trust_screen) run_ratatui_app(cli, config, should_show_trust_screen)
.await
.map_err(|err| std::io::Error::other(err.to_string())) .map_err(|err| std::io::Error::other(err.to_string()))
} }
fn run_ratatui_app( async fn run_ratatui_app(
cli: Cli, cli: Cli,
config: Config, config: Config,
should_show_trust_screen: bool, should_show_trust_screen: bool,
@@ -275,7 +276,7 @@ fn run_ratatui_app(
let Cli { prompt, images, .. } = cli; let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen); 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(); let usage = app.token_usage();
restore(); restore();

View File

@@ -213,11 +213,11 @@ mod tests {
use super::*; use super::*;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use std::sync::mpsc::channel; use tokio::sync::mpsc::unbounded_channel;
#[test] #[test]
fn renders_without_left_border_or_padding() { 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 tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx); let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hello".to_string()); w.restart_with_text("Hello".to_string());
@@ -235,7 +235,7 @@ mod tests {
#[test] #[test]
fn working_header_is_present_on_last_line() { 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 tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx); let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hi".to_string()); w.restart_with_text("Hi".to_string());
@@ -256,7 +256,7 @@ mod tests {
#[test] #[test]
fn header_starts_at_expected_position() { 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 tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx); let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hello".to_string()); w.restart_with_text("Hello".to_string());

View File

@@ -424,11 +424,11 @@ mod tests {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
use std::sync::mpsc::channel; use tokio::sync::mpsc::unbounded_channel;
#[test] #[test]
fn lowercase_shortcut_is_accepted() { 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 tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec { let req = ApprovalRequest::Exec {
id: "1".to_string(), id: "1".to_string(),
@@ -438,7 +438,10 @@ mod tests {
let mut widget = UserApprovalWidget::new(req, tx); let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(widget.is_complete()); 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!( assert!(events.iter().any(|e| matches!(
e, e,
AppEvent::CodexOp(Op::ExecApproval { AppEvent::CodexOp(Op::ExecApproval {
@@ -450,7 +453,7 @@ mod tests {
#[test] #[test]
fn uppercase_shortcut_is_accepted() { 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 tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec { let req = ApprovalRequest::Exec {
id: "2".to_string(), id: "2".to_string(),
@@ -460,7 +463,10 @@ mod tests {
let mut widget = UserApprovalWidget::new(req, tx); let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE)); widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE));
assert!(widget.is_complete()); 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!( assert!(events.iter().any(|e| matches!(
e, e,
AppEvent::CodexOp(Op::ExecApproval { AppEvent::CodexOp(Op::ExecApproval {