From ecce09c53e2e0fbc8d173c3b532c9874604f039c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 4 Jul 2026 16:53:26 -0300 Subject: [PATCH] refactor(tui): run live conversation panes --- codex-rs/tui/src/app.rs | 15 +- codex-rs/tui/src/app/app_server_events.rs | 4 +- codex-rs/tui/src/app/background_requests.rs | 2 +- codex-rs/tui/src/app/conversation_events.rs | 4 +- .../tui/src/app/conversation_events_tests.rs | 8 +- codex-rs/tui/src/app/conversation_panes.rs | 172 ++++++++++++++++- .../tui/src/app/conversation_panes_tests.rs | 174 ++++++++++++++++++ codex-rs/tui/src/app/event_dispatch.rs | 13 +- codex-rs/tui/src/app/thread_routing.rs | 50 ++++- codex-rs/tui/src/chatwidget/constructor.rs | 9 +- codex-rs/tui/src/chatwidget/tests.rs | 1 + .../tui/src/chatwidget/tests/constructor.rs | 67 +++++++ 12 files changed, 485 insertions(+), 34 deletions(-) create mode 100644 codex-rs/tui/src/app/conversation_panes_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/constructor.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a0766f4f91..1da0e0ae6c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -10,6 +10,7 @@ use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::HistoryLookupResponse; +use crate::app_event::PaneSlot; use crate::app_event::PermissionProfileSelection; use crate::app_event::PluginLocation; use crate::app_event::PluginRemoteSectionError; @@ -1173,22 +1174,16 @@ See the Codex keymap documentation for supported actions and examples." Err(err) => break Err(err), } } - active = async { - if let Some(rx) = app.chat_widget.active_thread_rx.as_mut() { - rx.recv().await - } else { - None - } - }, if App::should_handle_active_thread_events( + (pane, active) = app.chat_widget.recv_thread_event(), if App::should_handle_active_thread_events( waiting_for_initial_session_configured, - app.chat_widget.active_thread_rx.is_some() + app.chat_widget.has_thread_event_receiver() ) => { if let Some(event) = active { - if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await { + if let Err(err) = app.handle_pane_thread_event(tui, &mut app_server, pane, event).await { break Err(err); } } else { - app.clear_active_thread().await; + app.clear_pane_thread(pane).await; } AppRunControl::Continue } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 82facafa4f..cda977096a 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -71,9 +71,9 @@ impl App { if let Some(request) = self .pending_app_server_requests .resolve_notification(thread_id, ¬ification.request_id) - && self.chat_widget.active_thread_id == Some(thread_id) + && let Some(pane) = self.chat_widget.by_thread_id_mut(thread_id) { - self.chat_widget.dismiss_app_server_request(&request); + pane.dismiss_app_server_request(&request); } } Err(err) => { diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 0ee0dbe940..46682c2ceb 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -58,7 +58,7 @@ impl App { fn mcp_inventory_request_thread_id(&self, thread_id: Option) -> Option { thread_id.filter(|thread_id| { - self.chat_widget.active_thread_id == Some(*thread_id) + self.chat_widget.contains_thread(*thread_id) && self .agent_navigation .get(thread_id) diff --git a/codex-rs/tui/src/app/conversation_events.rs b/codex-rs/tui/src/app/conversation_events.rs index 237eab0767..1db08ceb66 100644 --- a/codex-rs/tui/src/app/conversation_events.rs +++ b/codex-rs/tui/src/app/conversation_events.rs @@ -9,10 +9,10 @@ use crate::app_event::ConversationOrigin; pub(super) fn normalize_conversation_event( event: AppEvent, - current_origin: Option, + is_live_origin: impl FnOnce(ConversationOrigin) -> bool, ) -> Option<(Option, AppEvent)> { match event { - AppEvent::FromConversation { target, event } if current_origin == Some(target) => { + AppEvent::FromConversation { target, event } if is_live_origin(target) => { Some((Some(target), *event)) } AppEvent::FromConversation { event, .. } if survives_conversation_retirement(&event) => { diff --git a/codex-rs/tui/src/app/conversation_events_tests.rs b/codex-rs/tui/src/app/conversation_events_tests.rs index 9f48b428fc..80918fe270 100644 --- a/codex-rs/tui/src/app/conversation_events_tests.rs +++ b/codex-rs/tui/src/app/conversation_events_tests.rs @@ -19,7 +19,7 @@ fn current_generation_dispatches_to_its_conversation() { target: current, event: Box::new(AppEvent::RawOutputModeChanged { enabled: true }), }, - Some(current), + |origin| origin == current, ) .expect("current event should be delivered"); @@ -41,7 +41,7 @@ fn stale_presentation_event_is_dropped() { target: stale, event: Box::new(AppEvent::RawOutputModeChanged { enabled: true }), }, - Some(current), + |origin| origin == current, ) .is_none() ); @@ -58,7 +58,7 @@ fn stale_widget_completion_is_dropped() { target: stale, event: Box::new(AppEvent::PluginMentionsLoaded { plugins: None }), }, - Some(current), + |origin| origin == current, ) .is_none() ); @@ -73,7 +73,7 @@ fn stale_durable_event_is_delivered_without_pane_dispatch() { target: stale, event: Box::new(AppEvent::Exit(ExitMode::ShutdownFirst)), }, - Some(current), + |origin| origin == current, ) .expect("durable event should be delivered"); diff --git a/codex-rs/tui/src/app/conversation_panes.rs b/codex-rs/tui/src/app/conversation_panes.rs index 04c33639d3..95ced393e3 100644 --- a/codex-rs/tui/src/app/conversation_panes.rs +++ b/codex-rs/tui/src/app/conversation_panes.rs @@ -1,7 +1,8 @@ //! Conversation-local state for the application-owned conversation surface. //! -//! This first stage places the current conversation behind a pane boundary. Later split-screen -//! layers can add another pane without returning these fields to application-global state. +//! The owned screen supports a fixed parent pane and an optional side pane. Focus controls where +//! user input goes, while a temporary dispatch slot lets asynchronous events update their origin +//! pane without changing focus. use std::ops::Deref; use std::ops::DerefMut; @@ -14,6 +15,7 @@ use tokio::sync::mpsc; use super::InitialHistoryReplayBuffer; use super::owned_screen::OwnedScreen; use super::thread_events::ThreadBufferedEvent; +use crate::app_event::ConversationOrigin; use crate::app_event::PaneSlot; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; @@ -27,6 +29,7 @@ pub(super) struct ConversationPaneInit { } pub(crate) struct ConversationPane { + slot: PaneSlot, pub(super) chat_widget: ChatWidget, pub(super) file_search: FileSearchManager, pub(crate) transcript_cells: Vec>, @@ -39,16 +42,17 @@ pub(crate) struct ConversationPane { } impl ConversationPane { - fn new(init: ConversationPaneInit) -> Result { + fn new(init: ConversationPaneInit, slot: PaneSlot) -> Result { if init .chat_widget .conversation_origin() - .is_some_and(|origin| origin.pane != PaneSlot::Parent) + .is_some_and(|origin| origin.pane != slot) { return Err(init); } Ok(Self { + slot, chat_widget: init.chat_widget, file_search: init.file_search, transcript_cells: Vec::new(), @@ -61,6 +65,16 @@ impl ConversationPane { }) } + pub(super) fn origin(&self) -> Option { + self.chat_widget + .conversation_origin() + .filter(|origin| origin.pane == self.slot) + } + + fn thread_id(&self) -> Option { + self.active_thread_id.or(self.chat_widget.thread_id()) + } + pub(super) fn attach_thread( &mut self, thread_id: ThreadId, @@ -96,17 +110,155 @@ impl DerefMut for ConversationPane { pub(crate) struct ConversationPanes { parent: ConversationPane, + side: Option, + focused: PaneSlot, + dispatch: Option, } +// Rendering and `/side` lifecycle consume the remaining APIs in the next stack layers. +#[allow(dead_code)] impl ConversationPanes { pub(super) fn new_parent(init: ConversationPaneInit) -> Result { Ok(Self { - parent: ConversationPane::new(init)?, + parent: ConversationPane::new(init, PaneSlot::Parent)?, + side: None, + focused: PaneSlot::Parent, + dispatch: None, }) } + pub(super) fn focused_slot(&self) -> PaneSlot { + self.focused + } + + pub(super) fn focus(&mut self, slot: PaneSlot) -> bool { + if self.by_slot(slot).is_none() { + return false; + } + self.focused = slot; + true + } + + pub(super) fn install_side( + &mut self, + init: ConversationPaneInit, + ) -> Result, ConversationPaneInit> { + let pane = ConversationPane::new(init, PaneSlot::Side)?; + Ok(self.side.replace(pane)) + } + + pub(super) fn take_side(&mut self) -> Option { + if self.focused == PaneSlot::Side { + self.focused = PaneSlot::Parent; + } + if self.dispatch == Some(PaneSlot::Side) { + self.dispatch = None; + } + self.side.take() + } + + pub(super) fn by_slot(&self, slot: PaneSlot) -> Option<&ConversationPane> { + match slot { + PaneSlot::Parent => Some(&self.parent), + PaneSlot::Side => self.side.as_ref(), + } + } + + pub(super) fn by_slot_mut(&mut self, slot: PaneSlot) -> Option<&mut ConversationPane> { + match slot { + PaneSlot::Parent => Some(&mut self.parent), + PaneSlot::Side => self.side.as_mut(), + } + } + + pub(super) fn by_origin(&self, origin: ConversationOrigin) -> Option<&ConversationPane> { + self.by_slot(origin.pane) + .filter(|pane| pane.origin() == Some(origin)) + } + + pub(super) fn by_thread_id(&self, thread_id: ThreadId) -> Option<&ConversationPane> { + if self.parent.thread_id() == Some(thread_id) { + return Some(&self.parent); + } + self.side + .as_ref() + .filter(|pane| pane.thread_id() == Some(thread_id)) + } + + pub(super) fn by_thread_id_mut( + &mut self, + thread_id: ThreadId, + ) -> Option<&mut ConversationPane> { + if self.parent.thread_id() == Some(thread_id) { + return Some(&mut self.parent); + } + self.side + .as_mut() + .filter(|pane| pane.thread_id() == Some(thread_id)) + } + + pub(super) fn contains_thread(&self, thread_id: ThreadId) -> bool { + self.by_thread_id(thread_id).is_some() + } + + pub(super) fn dispatch_to(&mut self, origin: ConversationOrigin) -> bool { + if self.by_origin(origin).is_none() { + return false; + } + self.dispatch = Some(origin.pane); + true + } + + pub(super) fn clear_dispatch(&mut self) -> Option { + self.dispatch.take() + } + + pub(super) fn finish_dispatch(&mut self, origin: ConversationOrigin) -> bool { + match self.clear_dispatch() { + Some(slot) => slot == origin.pane, + None => self.by_origin(origin).is_none(), + } + } + pub(super) fn selected_mut(&mut self) -> &mut ConversationPane { - &mut self.parent + let slot = self.dispatch.unwrap_or(self.focused); + self.by_slot_mut(slot) + .expect("focused or dispatch pane must be installed") + } + + pub(super) fn has_thread_event_receiver(&self) -> bool { + self.parent.active_thread_rx.is_some() + || self + .side + .as_ref() + .is_some_and(|pane| pane.active_thread_rx.is_some()) + } + + /// Waits fairly for an event from either installed pane. + pub(super) async fn recv_thread_event(&mut self) -> (PaneSlot, Option) { + let parent_rx = self.parent.active_thread_rx.as_mut(); + let side_rx = self + .side + .as_mut() + .and_then(|pane| pane.active_thread_rx.as_mut()); + + match (parent_rx, side_rx) { + (Some(parent_rx), Some(side_rx)) => { + tokio::select! { + event = parent_rx.recv() => (PaneSlot::Parent, event), + event = side_rx.recv() => (PaneSlot::Side, event), + } + } + (Some(parent_rx), None) => (PaneSlot::Parent, parent_rx.recv().await), + (None, Some(side_rx)) => (PaneSlot::Side, side_rx.recv().await), + (None, None) => (self.focused, None), + } + } + + fn selected(&self) -> &ConversationPane { + let slot = self.dispatch.unwrap_or(self.focused); + self.by_slot(slot) + .expect("focused or dispatch pane must be installed") } } @@ -114,12 +266,16 @@ impl Deref for ConversationPanes { type Target = ConversationPane; fn deref(&self) -> &Self::Target { - &self.parent + self.selected() } } impl DerefMut for ConversationPanes { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.parent + self.selected_mut() } } + +#[cfg(test)] +#[path = "conversation_panes_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/conversation_panes_tests.rs b/codex-rs/tui/src/app/conversation_panes_tests.rs new file mode 100644 index 0000000000..b9e3a3b032 --- /dev/null +++ b/codex-rs/tui/src/app/conversation_panes_tests.rs @@ -0,0 +1,174 @@ +use super::*; +use crate::app_event::HistoryLookupResponse; +use crate::chatwidget::tests::constructor::make_chatwidget_for_pane; +use pretty_assertions::assert_eq; + +async fn pane_init( + slot: PaneSlot, +) -> ( + ConversationPaneInit, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (chat_widget, rx) = make_chatwidget_for_pane(slot).await; + let file_search = FileSearchManager::new( + chat_widget.config_ref().cwd.to_path_buf(), + chat_widget.conversation_event_sender(), + ); + ( + ConversationPaneInit { + chat_widget, + file_search, + owned_screen: None, + }, + rx, + ) +} + +fn buffered_event(offset: usize) -> ThreadBufferedEvent { + ThreadBufferedEvent::HistoryEntryResponse(HistoryLookupResponse { + offset, + log_id: offset as u64, + entry: Some(format!("entry {offset}")), + }) +} + +fn event_offset(event: ThreadBufferedEvent) -> usize { + let ThreadBufferedEvent::HistoryEntryResponse(response) = event else { + panic!("expected history entry response"); + }; + response.offset +} + +#[tokio::test] +async fn focus_and_dispatch_select_different_panes() { + let (parent, _parent_rx) = pane_init(PaneSlot::Parent).await; + let (side, _side_rx) = pane_init(PaneSlot::Side).await; + let Ok(mut panes) = ConversationPanes::new_parent(parent) else { + panic!("parent pane should install"); + }; + assert!(matches!(panes.install_side(side), Ok(None))); + let parent_origin = panes + .by_slot(PaneSlot::Parent) + .and_then(ConversationPane::origin) + .expect("parent origin"); + let side_origin = panes + .by_slot(PaneSlot::Side) + .and_then(ConversationPane::origin) + .expect("side origin"); + + assert!(panes.focus(PaneSlot::Side)); + assert_eq!(panes.focused_slot(), PaneSlot::Side); + assert_eq!(panes.conversation_origin(), Some(side_origin)); + assert!(panes.dispatch_to(parent_origin)); + assert_eq!(panes.conversation_origin(), Some(parent_origin)); + assert_eq!(panes.clear_dispatch(), Some(PaneSlot::Parent)); + assert_eq!(panes.conversation_origin(), Some(side_origin)); +} + +#[tokio::test] +async fn taking_focused_side_restores_parent_selection() { + let (parent, _parent_rx) = pane_init(PaneSlot::Parent).await; + let (side, _side_rx) = pane_init(PaneSlot::Side).await; + let Ok(mut panes) = ConversationPanes::new_parent(parent) else { + panic!("parent pane should install"); + }; + assert!(panes.install_side(side).is_ok()); + assert!(panes.focus(PaneSlot::Side)); + let side_origin = panes.conversation_origin().expect("side origin"); + assert!(panes.dispatch_to(side_origin)); + + let removed = panes.take_side().expect("installed side pane"); + + assert_eq!(removed.origin(), Some(side_origin)); + assert_eq!(panes.focused_slot(), PaneSlot::Parent); + assert_eq!(panes.clear_dispatch(), None); + assert!(panes.finish_dispatch(side_origin)); + assert!(panes.by_slot(PaneSlot::Side).is_none()); + assert_eq!( + panes.conversation_origin().map(|origin| origin.pane), + Some(PaneSlot::Parent) + ); +} + +#[tokio::test] +async fn a_closed_receiver_does_not_starve_the_other_pane() { + let (parent, _parent_app_rx) = pane_init(PaneSlot::Parent).await; + let (side, _side_app_rx) = pane_init(PaneSlot::Side).await; + let Ok(mut panes) = ConversationPanes::new_parent(parent) else { + panic!("parent pane should install"); + }; + assert!(panes.install_side(side).is_ok()); + let (parent_tx, parent_rx) = mpsc::channel(/*buffer*/ 1); + let (side_tx, side_rx) = mpsc::channel(/*buffer*/ 1); + panes + .by_slot_mut(PaneSlot::Parent) + .expect("parent pane") + .attach_thread(ThreadId::new(), Some(parent_rx)); + panes + .by_slot_mut(PaneSlot::Side) + .expect("side pane") + .attach_thread(ThreadId::new(), Some(side_rx)); + + drop(parent_tx); + let (slot, event) = panes.recv_thread_event().await; + assert_eq!(slot, PaneSlot::Parent); + assert!(event.is_none()); + panes + .by_slot_mut(PaneSlot::Parent) + .expect("parent pane") + .clear_thread(); + side_tx + .send(buffered_event(/*offset*/ 3)) + .await + .expect("side receiver"); + + let (slot, event) = panes.recv_thread_event().await; + assert_eq!(slot, PaneSlot::Side); + assert_eq!(event_offset(event.expect("side event")), 3); +} + +#[tokio::test] +async fn both_pane_receivers_are_serviced() { + let (parent, _parent_app_rx) = pane_init(PaneSlot::Parent).await; + let (side, _side_app_rx) = pane_init(PaneSlot::Side).await; + let Ok(mut panes) = ConversationPanes::new_parent(parent) else { + panic!("parent pane should install"); + }; + assert!(panes.install_side(side).is_ok()); + let (parent_tx, parent_rx) = mpsc::channel(/*buffer*/ 1); + let (side_tx, side_rx) = mpsc::channel(/*buffer*/ 1); + let parent_thread = ThreadId::new(); + let side_thread = ThreadId::new(); + panes + .by_slot_mut(PaneSlot::Parent) + .expect("parent pane") + .attach_thread(parent_thread, Some(parent_rx)); + panes + .by_slot_mut(PaneSlot::Side) + .expect("side pane") + .attach_thread(side_thread, Some(side_rx)); + parent_tx + .send(buffered_event(/*offset*/ 1)) + .await + .expect("parent receiver"); + side_tx + .send(buffered_event(/*offset*/ 2)) + .await + .expect("side receiver"); + + let first = panes.recv_thread_event().await; + let second = panes.recv_thread_event().await; + let mut received = [ + (first.0, event_offset(first.1.expect("first event"))), + (second.0, event_offset(second.1.expect("second event"))), + ]; + received.sort_by_key(|(slot, _)| match slot { + PaneSlot::Parent => 0, + PaneSlot::Side => 1, + }); + + assert_eq!(received, [(PaneSlot::Parent, 1), (PaneSlot::Side, 2)]); + assert!(panes.contains_thread(parent_thread)); + assert!(panes.contains_thread(side_thread)); + assert!(panes.has_thread_event_receiver()); +} diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0c1fa4a126..6f6ea70921 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -20,14 +20,18 @@ impl App { app_server: &mut AppServerSession, event: AppEvent, ) -> Result { - let Some((conversation_origin, event)) = - normalize_conversation_event(event, self.chat_widget.conversation_origin()) - else { + let Some((conversation_origin, event)) = normalize_conversation_event(event, |origin| { + self.chat_widget.by_origin(origin).is_some() + }) else { tracing::debug!("dropping event from retired conversation"); return Ok(AppRunControl::Continue); }; let previous_sender = if let Some(origin) = conversation_origin { + if !self.chat_widget.dispatch_to(origin) { + tracing::debug!(?origin, "dropping event from unavailable conversation pane"); + return Ok(AppRunControl::Continue); + } let sender = self.chat_widget.conversation_event_sender(); debug_assert_eq!(sender.conversation_origin(), Some(origin)); Some(std::mem::replace(&mut self.app_event_tx, sender)) @@ -38,6 +42,9 @@ impl App { if let Some(previous_sender) = previous_sender { self.app_event_tx = previous_sender; } + if let Some(origin) = conversation_origin { + debug_assert!(self.chat_widget.finish_dispatch(origin)); + } result } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index ca83e3313d..5ebf7ac6ca 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -839,7 +839,7 @@ impl App { let mut pending_thread_ids = Vec::new(); for (thread_id, store) in channels { - if Some(thread_id) == self.chat_widget.active_thread_id + if self.chat_widget.contains_thread(thread_id) || Some(thread_id) == side_parent_thread_id { continue; @@ -1020,7 +1020,7 @@ impl App { thread_id: ThreadId, request: ServerRequest, ) -> Result<()> { - let inactive_interactive_request = if self.chat_widget.active_thread_id != Some(thread_id) { + let inactive_interactive_request = if !self.chat_widget.contains_thread(thread_id) { self.interactive_request_for_thread_request(thread_id, &request) .await? } else { @@ -1509,6 +1509,52 @@ impl App { } } + pub(super) async fn clear_pane_thread(&mut self, pane: PaneSlot) { + let Some(active_thread_id) = self + .chat_widget + .by_slot(pane) + .and_then(|pane| pane.active_thread_id) + else { + if let Some(pane) = self.chat_widget.by_slot_mut(pane) { + pane.clear_thread(); + } + return; + }; + self.set_thread_active(active_thread_id, /*active*/ false) + .await; + if let Some(pane) = self.chat_widget.by_slot_mut(pane) { + pane.clear_thread(); + } + self.refresh_pending_thread_approvals().await; + } + + pub(super) async fn handle_pane_thread_event( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + pane: PaneSlot, + event: ThreadBufferedEvent, + ) -> Result<()> { + let Some(origin) = self + .chat_widget + .by_slot(pane) + .and_then(|pane| pane.origin()) + else { + return Ok(()); + }; + if !self.chat_widget.dispatch_to(origin) { + return Ok(()); + } + let sender = self.chat_widget.conversation_event_sender(); + let previous_sender = std::mem::replace(&mut self.app_event_tx, sender); + let result = self + .handle_active_thread_event(tui, app_server, event) + .await; + self.app_event_tx = previous_sender; + debug_assert!(self.chat_widget.finish_dispatch(origin)); + result + } + /// Handles an event emitted by the currently active thread. /// /// This function enforces shutdown intent routing: unexpected non-primary diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index e9d720859e..4d70cb7cc0 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -3,8 +3,13 @@ use super::*; impl ChatWidget { - pub(crate) fn new_with_app_event(mut common: ChatWidgetInit) -> Self { - common.app_event_tx = common.app_event_tx.scoped_to_conversation(PaneSlot::Parent); + pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { + Self::new_with_app_event_for_pane(common, PaneSlot::Parent) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn new_with_app_event_for_pane(mut common: ChatWidgetInit, pane: PaneSlot) -> Self { + common.app_event_tx = common.app_event_tx.scoped_to_conversation(pane); Self::new_with_op_target(common, CodexOpTarget::AppEvent) } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 24423d3737..24ef4d5761 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -236,6 +236,7 @@ mod approval_requests; mod composer_submission; #[path = "tests/config_errors_tests.rs"] mod config_errors; +pub(crate) mod constructor; mod exec_flow; mod goal_menu; mod goal_validation; diff --git a/codex-rs/tui/src/chatwidget/tests/constructor.rs b/codex-rs/tui/src/chatwidget/tests/constructor.rs new file mode 100644 index 0000000000..54d8f22087 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/constructor.rs @@ -0,0 +1,67 @@ +use super::*; + +async fn test_init() -> ( + ChatWidgetInit, + tokio::sync::mpsc::UnboundedReceiver, +) { + let config = test_config().await; + let model = get_model_offline_for_tests(config.model.as_deref()); + let session_telemetry = test_session_telemetry(&config, model.as_str()); + let model_catalog = test_model_catalog(&config); + let (tx, rx) = unbounded_channel(); + + ( + ChatWidgetInit { + config, + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(tx), + workspace_command_runner: None, + initial_user_message: None, + enhanced_keys_supported: false, + has_chatgpt_account: false, + has_codex_backend_auth: false, + model_catalog, + feedback: codex_feedback::CodexFeedback::new(), + is_first_run: false, + status_account_display: None, + runtime_model_provider_base_url: None, + initial_plan_type: None, + model: Some(model), + startup_tooltip_override: None, + status_line_invalid_items_warned: Arc::new(AtomicBool::new(/*v*/ false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(/*v*/ false)), + session_telemetry, + }, + rx, + ) +} + +pub(crate) async fn make_chatwidget_for_pane( + pane: PaneSlot, +) -> (ChatWidget, tokio::sync::mpsc::UnboundedReceiver) { + let (init, rx) = test_init().await; + (ChatWidget::new_with_app_event_for_pane(init, pane), rx) +} + +#[tokio::test] +async fn app_event_constructor_defaults_to_parent_pane() { + let (init, _rx) = test_init().await; + let widget = ChatWidget::new_with_app_event(init); + + assert_eq!( + widget.conversation_origin().map(|origin| origin.pane), + Some(PaneSlot::Parent) + ); +} + +#[tokio::test] +async fn app_event_constructor_uses_requested_pane_and_fresh_generation() { + let (first, _first_rx) = make_chatwidget_for_pane(PaneSlot::Side).await; + let (second, _second_rx) = make_chatwidget_for_pane(PaneSlot::Side).await; + let first_origin = first.conversation_origin().expect("scoped first widget"); + let second_origin = second.conversation_origin().expect("scoped second widget"); + + assert_eq!(first_origin.pane, PaneSlot::Side); + assert_eq!(second_origin.pane, PaneSlot::Side); + assert_ne!(first_origin.generation, second_origin.generation); +}