fix(tui): synchronize shared pane state

This commit is contained in:
Felipe Coury
2026-07-04 19:25:32 -03:00
parent 95ac6b2bfc
commit 894e9b2ac1
13 changed files with 650 additions and 95 deletions
+42 -28
View File
@@ -25,8 +25,9 @@ impl App {
.iter()
.filter_map(|(name, server)| server.enabled.then_some(name.clone()))
.collect();
self.chat_widget
.set_mcp_startup_expected_servers(enabled_config_mcp_servers);
self.chat_widget.for_each_installed_mut(|pane| {
pane.set_mcp_startup_expected_servers(enabled_config_mcp_servers.iter().cloned());
});
}
pub(super) async fn handle_app_server_event(
@@ -41,7 +42,8 @@ impl App {
"app-server event consumer lagged; dropping ignored events"
);
self.refresh_mcp_startup_expected_servers_from_config();
self.chat_widget.finish_mcp_startup_after_lag();
self.chat_widget
.for_each_installed_mut(|pane| pane.finish_mcp_startup_after_lag());
}
AppServerEvent::ServerNotification(notification) => {
self.handle_server_notification_event(app_server_client, notification)
@@ -88,8 +90,9 @@ impl App {
self.refresh_mcp_startup_expected_servers_from_config();
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget
.on_rolling_rate_limit_snapshot(notification.rate_limits.clone());
self.chat_widget.for_each_installed_mut(|pane| {
pane.on_rolling_rate_limit_snapshot(notification.rate_limits.clone());
});
return;
}
ServerNotification::AccountUpdated(notification) => {
@@ -102,17 +105,21 @@ impl App {
| AuthMode::PersonalAccessToken
)
);
self.chat_widget.update_account_state(
status_account_display_from_auth_mode(
notification.auth_mode,
notification.plan_type,
),
let status_account_display = status_account_display_from_auth_mode(
notification.auth_mode,
notification.plan_type,
notification
.auth_mode
.is_some_and(AuthMode::has_chatgpt_account),
has_codex_backend_auth,
);
let has_chatgpt_account = notification
.auth_mode
.is_some_and(AuthMode::has_chatgpt_account);
self.chat_widget.for_each_installed_mut(|pane| {
pane.update_account_state(
status_account_display.clone(),
notification.plan_type,
has_chatgpt_account,
has_codex_backend_auth,
);
});
return;
}
ServerNotification::ExternalAgentConfigImportCompleted(notification) => {
@@ -125,7 +132,9 @@ impl App {
);
}
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.for_each_installed_mut(|pane| {
pane.refresh_plugin_mentions();
});
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.fetch_plugins_list(app_server_client, cwd);
if should_report_completion {
@@ -136,17 +145,17 @@ impl App {
return;
}
ServerNotification::AppListUpdated(notification) => {
self.chat_widget.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: notification
.data
.iter()
.cloned()
.map(app_info_from_api)
.collect(),
}),
/*is_final*/ false,
);
let snapshot = ConnectorsSnapshot {
connectors: notification
.data
.iter()
.cloned()
.map(app_info_from_api)
.collect(),
};
self.chat_widget.for_each_installed_mut(|pane| {
pane.on_connectors_loaded(Ok(snapshot.clone()), /*is_final*/ false);
});
return;
}
_ => {}
@@ -188,8 +197,9 @@ impl App {
ServerNotificationThreadTarget::Global => {}
}
self.chat_widget
.handle_server_notification(notification, /*replay_kind*/ None);
self.chat_widget.for_each_installed_mut(|pane| {
pane.handle_server_notification(notification.clone(), /*replay_kind*/ None);
});
}
async fn handle_server_request_event(
@@ -255,3 +265,7 @@ impl App {
}
}
}
#[cfg(test)]
#[path = "app_server_events_tests.rs"]
mod tests;
@@ -0,0 +1,149 @@
use super::*;
use crate::app::conversation_panes::ConversationPaneInit;
use crate::app::test_support::make_test_app_with_channels;
use crate::app_event::PaneSlot;
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender;
use crate::file_search::FileSearchManager;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_protocol::account::PlanType;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::UnboundedReceiver;
async fn install_test_side(app: &mut App) {
let side_widget =
make_chatwidget_for_pane_with_sender(PaneSlot::Side, app.app_event_tx.clone()).await;
let file_search = FileSearchManager::new(
side_widget.config_ref().cwd.to_path_buf(),
side_widget.conversation_event_sender(),
);
assert!(
app.chat_widget
.install_side(ConversationPaneInit {
chat_widget: side_widget,
file_search,
owned_screen: None,
})
.is_ok(),
"side pane should install"
);
}
fn take_history_messages(events: &mut UnboundedReceiver<AppEvent>) -> Vec<(PaneSlot, String)> {
let mut messages = Vec::new();
while let Ok(event) = events.try_recv() {
let (pane, event) = match event {
AppEvent::FromConversation { target, event } => (target.pane, *event),
event @ AppEvent::InsertHistoryCell(_) => (PaneSlot::Parent, event),
_ => continue,
};
let AppEvent::InsertHistoryCell(cell) = event else {
continue;
};
let text = cell
.display_lines(/*width*/ 80)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
messages.push((pane, text));
}
messages
}
#[tokio::test]
async fn account_and_global_notifications_reach_both_installed_panes() {
let (mut app, mut app_events, _op_rx) = make_test_app_with_channels().await;
install_test_side(&mut app).await;
let app_server = crate::start_embedded_app_server_for_picker(&app.config)
.await
.expect("embedded app server should start");
app.handle_server_notification_event(
&app_server,
ServerNotification::AccountUpdated(AccountUpdatedNotification {
auth_mode: Some(AuthMode::Chatgpt),
plan_type: Some(PlanType::Plus),
}),
)
.await;
for slot in [PaneSlot::Parent, PaneSlot::Side] {
let pane = app.chat_widget.by_slot(slot).expect("installed pane");
assert_eq!(pane.current_plan_type(), Some(PlanType::Plus));
assert!(pane.has_chatgpt_account());
assert!(pane.has_codex_backend_auth());
}
while app_events.try_recv().is_ok() {}
app.handle_server_notification_event(
&app_server,
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "shared warning".to_string(),
details: None,
path: None,
range: None,
}),
)
.await;
assert_eq!(
take_history_messages(&mut app_events),
vec![
(PaneSlot::Parent, "⚠ shared warning".to_string()),
(PaneSlot::Side, "⚠ shared warning".to_string()),
]
);
}
#[tokio::test]
async fn lag_finishes_mcp_startup_in_both_installed_panes() {
let (mut app, mut app_events, _op_rx) = make_test_app_with_channels().await;
install_test_side(&mut app).await;
let sentry_config = toml::from_str::<toml::Value>("command = 'true'")
.expect("test MCP config should parse")
.try_into()
.expect("test MCP config should deserialize");
app.config
.mcp_servers
.set(std::collections::HashMap::from([(
"sentry".to_string(),
sentry_config,
)]))
.expect("test MCP servers should accept any configuration");
let app_server = crate::start_embedded_app_server_for_picker(&app.config)
.await
.expect("embedded app server should start");
let starting = ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: None,
name: "alpha".to_string(),
status: McpServerStartupState::Starting,
error: None,
failure_reason: None,
});
app.chat_widget.for_each_installed_mut(|pane| {
pane.handle_server_notification(starting.clone(), /*replay_kind*/ None);
});
while app_events.try_recv().is_ok() {}
app.handle_app_server_event(&app_server, AppServerEvent::Lagged { skipped: 1 })
.await;
let warnings = take_history_messages(&mut app_events);
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].0, PaneSlot::Parent);
assert_eq!(warnings[1].0, PaneSlot::Side);
assert!(
warnings
.iter()
.all(|(_, text)| text.contains("alpha") && text.contains("sentry")),
"each pane should report both observed and configured MCP servers: {warnings:?}"
);
}
+95 -37
View File
@@ -211,7 +211,10 @@ impl App {
.await?;
self.apply_runtime_policy_overrides(&mut config);
self.config = config;
self.chat_widget.sync_plugin_mentions_config(&self.config);
let config = &self.config;
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.sync_plugin_mentions_config(config);
});
Ok(())
}
@@ -530,8 +533,10 @@ impl App {
*feature == Feature::MemoryTool && *enabled && !memory_tool_was_enabled
});
for (feature, effective_enabled) in feature_updates_to_apply {
self.chat_widget
.set_feature_enabled(feature, effective_enabled);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget
.set_feature_enabled(feature, effective_enabled);
});
}
if show_memory_enable_notice {
self.chat_widget.add_memories_enable_notice();
@@ -658,8 +663,10 @@ impl App {
self.config.memories.use_memories = use_memories;
self.config.memories.generate_memories = generate_memories;
self.chat_widget
.set_memory_settings(use_memories, generate_memories);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget
.set_memory_settings(use_memories, generate_memories);
});
true
}
@@ -669,7 +676,6 @@ impl App {
use_memories: bool,
generate_memories: bool,
) {
let previous_generate_memories = self.config.memories.generate_memories;
if !self
.update_memory_settings(app_server, use_memories, generate_memories)
.await
@@ -678,25 +684,28 @@ impl App {
}
let generate_memories = self.config.memories.generate_memories;
if previous_generate_memories == generate_memories {
return;
}
let Some(thread_id) = self.current_displayed_thread_id() else {
return;
};
let mode = if generate_memories {
ThreadMemoryMode::Enabled
} else {
ThreadMemoryMode::Disabled
};
let thread_ids: Vec<_> = self
.chat_widget
.installed_thread_ids()
.into_iter()
.filter(|thread_id| !self.is_thread_retired(thread_id))
.collect();
if let Err(err) = app_server.thread_memory_mode_set(thread_id, mode).await {
tracing::error!(error = %err, %thread_id, "failed to update thread memory mode");
self.chat_widget.add_error_message(format!(
"Saved memory settings, but failed to update the current thread: {err}"
));
for thread_id in thread_ids {
if let Err(err) = app_server
.thread_memory_mode_set(thread_id, mode.clone())
.await
{
tracing::error!(error = %err, %thread_id, "failed to update thread memory mode");
self.chat_widget.add_error_message(format!(
"Saved memory settings, but failed to update thread {thread_id}: {err}"
));
}
}
}
@@ -747,19 +756,25 @@ impl App {
pub(super) fn sync_tui_theme_selection(&mut self, name: String) {
self.config.tui_theme = Some(name.clone());
self.chat_widget.set_tui_theme(Some(name));
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.set_tui_theme(Some(name.clone()));
});
}
#[cfg(test)]
pub(super) fn sync_tui_pet_selection(&mut self, pet: String) {
self.config.tui_pet = Some(pet.clone());
self.chat_widget.set_tui_pet(Some(pet));
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.set_tui_pet(Some(pet.clone()));
});
}
pub(super) fn sync_tui_pet_disabled(&mut self) {
let pet = crate::pets::DISABLED_PET_ID.to_string();
self.config.tui_pet = Some(pet.clone());
self.chat_widget.set_tui_pet(Some(pet));
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.set_tui_pet(Some(pet.clone()));
});
}
pub(super) fn restore_runtime_theme_from_config(&self) {
@@ -803,7 +818,9 @@ impl App {
);
continue;
}
self.chat_widget.set_feature_enabled(*feature, enabled);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.set_feature_enabled(*feature, enabled);
});
}
if feature_updates
@@ -928,8 +945,10 @@ impl App {
.unwrap_or(self.config.memories.generate_memories);
self.config.memories.use_memories = use_memories;
self.config.memories.generate_memories = generate_memories;
self.chat_widget
.set_memory_settings(use_memories, generate_memories);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget
.set_memory_settings(use_memories, generate_memories);
});
true
}
@@ -1062,12 +1081,27 @@ mod tests {
use super::*;
use crate::app::test_support::app_enabled_in_effective_config;
use crate::app::test_support::make_test_app;
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::test_support::PathBufExt;
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
async fn install_test_side(app: &mut App) {
let (side_widget, _side_rx) = make_chatwidget_for_pane(PaneSlot::Side).await;
let file_search = FileSearchManager::new(
side_widget.config_ref().cwd.to_path_buf(),
side_widget.conversation_event_sender(),
);
let installed = app.chat_widget.install_side(ConversationPaneInit {
chat_widget: side_widget,
file_search,
owned_screen: None,
});
assert!(installed.is_ok(), "side pane should install");
}
#[tokio::test]
async fn update_reasoning_effort_updates_collaboration_mode() {
let mut app = make_test_app().await;
@@ -1342,32 +1376,49 @@ terminal_resize_reflow_max_rows = 9000
#[tokio::test]
async fn sync_tui_theme_selection_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;
install_test_side(&mut app).await;
app.sync_tui_theme_selection("dracula".to_string());
assert_eq!(app.config.tui_theme.as_deref(), Some("dracula"));
assert_eq!(
app.chat_widget.config_ref().tui_theme.as_deref(),
Some("dracula")
);
for slot in [PaneSlot::Parent, PaneSlot::Side] {
assert_eq!(
app.chat_widget
.by_slot(slot)
.expect("installed pane")
.config_ref()
.tui_theme
.as_deref(),
Some("dracula")
);
}
}
#[tokio::test]
async fn sync_tui_pet_selection_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;
install_test_side(&mut app).await;
app.sync_tui_pet_selection("chefito".to_string());
assert_eq!(app.config.tui_pet.as_deref(), Some("chefito"));
assert_eq!(
app.chat_widget.config_ref().tui_pet.as_deref(),
Some("chefito")
);
for slot in [PaneSlot::Parent, PaneSlot::Side] {
assert_eq!(
app.chat_widget
.by_slot(slot)
.expect("installed pane")
.config_ref()
.tui_pet
.as_deref(),
Some("chefito")
);
}
}
#[tokio::test]
async fn sync_tui_pet_disabled_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;
install_test_side(&mut app).await;
app.sync_tui_pet_disabled();
@@ -1375,9 +1426,16 @@ terminal_resize_reflow_max_rows = 9000
app.config.tui_pet.as_deref(),
Some(crate::pets::DISABLED_PET_ID)
);
assert_eq!(
app.chat_widget.config_ref().tui_pet.as_deref(),
Some(crate::pets::DISABLED_PET_ID)
);
for slot in [PaneSlot::Parent, PaneSlot::Side] {
assert_eq!(
app.chat_widget
.by_slot(slot)
.expect("installed pane")
.config_ref()
.tui_pet
.as_deref(),
Some(crate::pets::DISABLED_PET_ID)
);
}
}
}
@@ -233,6 +233,13 @@ impl ConversationPanes {
thread_ids
}
pub(super) fn for_each_installed_mut(&mut self, mut visit: impl FnMut(&mut ConversationPane)) {
visit(&mut self.parent);
if let Some(side) = &mut self.side {
visit(side);
}
}
pub(super) fn dispatch_to(&mut self, origin: ConversationOrigin) -> bool {
if self.by_origin(origin).is_none() {
return false;
@@ -127,6 +127,44 @@ async fn installed_thread_ids_are_parent_first_and_unique() {
assert_eq!(panes.installed_thread_ids(), vec![parent_thread_id]);
}
#[tokio::test]
async fn for_each_installed_mut_visits_parent_then_side_without_changing_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 parent_origin = panes
.by_slot(PaneSlot::Parent)
.and_then(ConversationPane::origin)
.expect("parent origin");
assert!(panes.dispatch_to(parent_origin));
let mut visited = Vec::new();
panes.for_each_installed_mut(|pane| visited.push(pane.slot));
assert_eq!(visited, vec![PaneSlot::Parent, PaneSlot::Side]);
assert_eq!(panes.focused, PaneSlot::Side);
assert_eq!(panes.dispatch, Some(PaneSlot::Parent));
}
#[tokio::test]
async fn for_each_installed_mut_visits_only_parent_without_a_side_pane() {
let (parent, _parent_rx) = pane_init(PaneSlot::Parent).await;
let Ok(mut panes) = ConversationPanes::new_parent(parent) else {
panic!("parent pane should install");
};
let mut visited = Vec::new();
panes.for_each_installed_mut(|pane| visited.push(pane.slot));
assert_eq!(visited, vec![PaneSlot::Parent]);
assert_eq!(panes.focused, PaneSlot::Parent);
assert_eq!(panes.dispatch, None);
}
#[tokio::test]
async fn a_closed_receiver_does_not_starve_the_other_pane() {
let (parent, _parent_app_rx) = pane_init(PaneSlot::Parent).await;
+42 -12
View File
@@ -1861,18 +1861,33 @@ impl App {
self.windows_sandbox.skip_world_writable_scan_once = true;
}
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
self.chat_widget.set_full_access_warning_acknowledged(ack);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.set_full_access_warning_acknowledged(ack);
});
}
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
self.chat_widget
.set_world_writable_warning_acknowledged(ack);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget
.set_world_writable_warning_acknowledged(ack);
});
}
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
});
}
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
self.config.plan_mode_reasoning_effort = effort.clone();
self.chat_widget.set_plan_mode_reasoning_effort(effort);
let selected_origin = self.chat_widget.origin();
self.chat_widget.for_each_installed_mut(|pane| {
if pane.origin() == selected_origin {
pane.chat_widget
.set_plan_mode_reasoning_effort(effort.clone());
} else {
pane.chat_widget
.sync_plan_mode_reasoning_effort_config(effort.clone());
}
});
self.sync_active_thread_plan_mode_reasoning_setting(app_server)
.await;
}
@@ -1997,7 +2012,9 @@ impl App {
.await
{
Ok(()) => {
self.chat_widget.update_skill_enabled(path, enabled);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.update_skill_enabled(path.clone(), enabled);
});
}
Err(err) => {
let path_display = path.display();
@@ -2033,7 +2050,9 @@ impl App {
.await
{
Ok(_) => {
self.chat_widget.update_connector_enabled(&id, enabled);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.update_connector_enabled(&id, enabled);
});
}
Err(err) => {
self.chat_widget.add_error_message(format!(
@@ -2190,7 +2209,10 @@ impl App {
Ok(()) => {
self.config.tui_status_line = Some(ids.clone());
self.config.tui_status_line_use_colors = use_theme_colors;
self.chat_widget.setup_status_line(items, use_theme_colors);
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget
.setup_status_line(items.clone(), use_theme_colors);
});
}
Err(err) => {
let error = format_config_error(&err);
@@ -2266,12 +2288,16 @@ impl App {
crate::render::highlight::set_syntax_theme(theme);
}
self.sync_tui_theme_selection(name);
self.refresh_status_line();
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.refresh_status_line();
});
tui.frame_requester().schedule_frame();
}
Err(err) => {
self.restore_runtime_theme_from_config();
self.refresh_status_line();
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.refresh_status_line();
});
tracing::error!(error = %err, "failed to persist theme selection");
self.chat_widget
.add_error_message(format!("Failed to save theme: {err}"));
@@ -2279,7 +2305,9 @@ impl App {
}
}
AppEvent::SyntaxThemePreviewed => {
self.refresh_status_line();
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.refresh_status_line();
});
tui.frame_requester().schedule_frame();
}
AppEvent::OpenKeymapActionMenu { context, action } => {
@@ -2386,7 +2414,9 @@ impl App {
}
fn refresh_plugin_mentions_after_config_write(&mut self) {
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.refresh_plugin_mentions();
});
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
+3 -2
View File
@@ -78,9 +78,10 @@ impl App {
) {
if notify {
self.chat_widget.set_raw_output_mode_and_notify(enabled);
} else {
self.chat_widget.set_raw_output_mode(enabled);
}
self.chat_widget.for_each_installed_mut(|pane| {
pane.chat_widget.set_raw_output_mode(enabled);
});
self.sync_owned_screen_render_mode();
if self.has_owned_screen() {
tui.frame_requester().schedule_frame();
+6 -4
View File
@@ -392,10 +392,12 @@ impl App {
}
pub(super) fn sync_owned_screen_render_mode(&mut self) {
let render_mode = self.chat_widget.history_render_mode();
if let Some(screen) = &mut self.chat_widget.owned_screen {
screen.viewport.set_render_mode(render_mode);
}
self.chat_widget.for_each_installed_mut(|pane| {
let render_mode = pane.chat_widget.history_render_mode();
if let Some(screen) = &mut pane.owned_screen {
screen.viewport.set_render_mode(render_mode);
}
});
}
pub(super) fn handle_owned_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<bool> {
+141
View File
@@ -14,10 +14,12 @@ use super::super::conversation_panes::ConversationPaneInit;
use super::*;
use crate::app_event::PaneSlot;
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane;
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::file_search::FileSearchManager;
use crate::tui::MouseScrollDirection;
use crate::tui::MouseScrollEvent;
use codex_app_server_protocol::ConfigWarningNotification;
#[derive(Debug)]
struct TestCell(&'static str);
@@ -32,6 +34,22 @@ impl HistoryCell for TestCell {
}
}
#[derive(Debug)]
struct RenderModeCell {
display: &'static str,
raw: &'static str,
}
impl HistoryCell for RenderModeCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![self.display.into()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![self.raw.into()]
}
}
async fn app_with_owned_parent() -> App {
let mut app = super::super::test_support::make_test_app().await;
app.chat_widget.owned_screen = App::owned_screen_for_behavior(
@@ -202,6 +220,129 @@ async fn renders_wide_parent_left_and_side_right() {
);
}
#[tokio::test]
async fn raw_output_mode_fans_out_without_changing_focus_or_drafts() {
let mut app = app_with_owned_side().await;
for (slot, draft, display, raw) in [
(
PaneSlot::Parent,
"parent draft",
"parent rich transcript",
"parent raw transcript",
),
(
PaneSlot::Side,
"side draft",
"side rich transcript",
"side raw transcript",
),
] {
let pane = app.chat_widget.by_slot_mut(slot).expect("installed pane");
pane.chat_widget
.set_composer_text(draft.to_string(), Vec::new(), Vec::new());
pane.owned_screen
.as_mut()
.expect("owned screen")
.viewport
.push_cell(Arc::new(RenderModeCell { display, raw }));
}
assert!(app.chat_widget.focus(PaneSlot::Side));
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
app.apply_raw_output_mode(&mut tui, /*enabled*/ true, /*notify*/ false);
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
for (slot, draft) in [
(PaneSlot::Parent, "parent draft"),
(PaneSlot::Side, "side draft"),
] {
let pane = app.chat_widget.by_slot(slot).expect("installed pane");
assert!(pane.chat_widget.raw_output_mode());
assert_eq!(pane.chat_widget.composer_text_with_pending(), draft);
}
let terminal = render_app(&mut app, /*width*/ 83, /*height*/ 12);
assert_snapshot!("owned_screen_raw_output_mode_fans_out", terminal.backend());
}
#[tokio::test]
async fn global_warning_renders_in_both_owned_panes() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) =
super::super::test_support::make_test_app_with_channels().await;
app.chat_widget.owned_screen = App::owned_screen_for_behavior(
AltScreenBehavior::Owned,
&app.chat_widget,
app.keymap.pager.clone(),
);
let side_widget =
make_chatwidget_for_pane_with_sender(PaneSlot::Side, app.app_event_tx.clone()).await;
let file_search = FileSearchManager::new(
side_widget.config_ref().cwd.to_path_buf(),
side_widget.conversation_event_sender(),
);
let owned_screen = App::owned_screen_for_behavior(
AltScreenBehavior::Owned,
&side_widget,
app.keymap.pager.clone(),
);
assert!(
app.chat_widget
.install_side(ConversationPaneInit {
chat_widget: side_widget,
file_search,
owned_screen,
})
.is_ok(),
"side pane should install"
);
for (slot, draft) in [
(PaneSlot::Parent, "parent draft"),
(PaneSlot::Side, "side draft"),
] {
app.chat_widget
.by_slot_mut(slot)
.expect("installed pane")
.set_composer_text(draft.to_string(), Vec::new(), Vec::new());
}
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "Shared configuration warning".to_string(),
details: None,
path: None,
range: None,
}),
),
)
.await;
let mut tui = crate::tui::test_support::make_test_tui()?;
while let Ok(event) = app_event_rx.try_recv() {
app.handle_event(&mut tui, &mut app_server, event).await?;
}
assert_eq!(
app.chat_widget
.by_slot(PaneSlot::Parent)
.expect("parent pane")
.transcript_cells
.len(),
1
);
assert_eq!(
app.chat_widget
.by_slot(PaneSlot::Side)
.expect("side pane")
.transcript_cells
.len(),
1
);
let terminal = render_app(&mut app, /*width*/ 83, /*height*/ 18);
assert_snapshot!("owned_screen_global_warning_fans_out", terminal.backend());
Ok(())
}
#[tokio::test]
async fn renders_closed_parent_read_only_while_side_remains_focused() {
let mut app = app_with_owned_side().await;
@@ -0,0 +1,22 @@
---
source: tui/src/app/owned_screen_tests.rs
expression: terminal.backend()
---
" Parent ⌥ + 1 / ⌥ + 2 focus │ Side "
"⚠ Shared configuration warning │⚠ Shared configuration warning "
" │ "
" │╭───────────────────────────────────────╮"
" ││ >_ OpenAI Codex (v0.0.0) │"
" ││ │"
" ││ model: loading /model to change │"
" ││ directory: /tmp/project │"
" │╰───────────────────────────────────────╯"
" │ "
" │ "
" │ "
" │ "
" │ "
" │ "
"› parent draft │› side draft "
" │ "
" gpt-5.5 default · /tmp/project │ gpt-5.5 default · /tmp/project "
@@ -0,0 +1,16 @@
---
source: tui/src/app/owned_screen_tests.rs
expression: terminal.backend()
---
" Parent │ Side ⌥ + 1 / ⌥ + 2 focus "
"parent raw transcript │side raw transcript "
" │ "
" │OpenAI Codex (v0.0.0) "
" │model: loading "
" │directory: /tmp/project "
" │ "
" │ "
" │ "
"› parent draft │› side draft "
" │ "
" gpt-5.5 default · /tmp/project │ gpt-5.5 default · /tmp/project "
+81 -11
View File
@@ -1682,9 +1682,62 @@ async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Resu
Ok(())
}
#[tokio::test]
async fn update_plan_mode_reasoning_effort_updates_both_widget_configs() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let parent_thread_id = app_server
.start_thread(&app.config)
.await?
.session
.thread_id;
let side_thread_id = app_server
.start_thread(&app.config)
.await?
.session
.thread_id;
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
let side_origin = app
.chat_widget
.by_slot(PaneSlot::Side)
.and_then(super::conversation_panes::ConversationPane::origin)
.expect("side origin");
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::FromConversation {
target: side_origin,
event: Box::new(AppEvent::UpdatePlanModeReasoningEffort(Some(
ReasoningEffortConfig::High,
))),
},
)
.await?;
assert_eq!(
app.config.plan_mode_reasoning_effort,
Some(ReasoningEffortConfig::High)
);
for slot in [PaneSlot::Parent, PaneSlot::Side] {
assert_eq!(
app.chat_widget
.by_slot(slot)
.expect("installed pane")
.config_ref()
.plan_mode_reasoning_effort,
Some(ReasoningEffortConfig::High)
);
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn update_memory_settings_persists_and_updates_widget_config() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
install_test_side_pane(&mut app, ThreadId::new(), ThreadId::new()).await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
@@ -1698,8 +1751,15 @@ async fn update_memory_settings_persists_and_updates_widget_config() -> Result<(
assert!(!app.config.memories.use_memories);
assert!(!app.config.memories.generate_memories);
assert!(!app.chat_widget.config_ref().memories.use_memories);
assert!(!app.chat_widget.config_ref().memories.generate_memories);
for slot in [PaneSlot::Parent, PaneSlot::Side] {
let config = app
.chat_widget
.by_slot(slot)
.expect("installed pane")
.config_ref();
assert!(!config.memories.use_memories);
assert!(!config.memories.generate_memories);
}
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let config_value = toml::from_str::<TomlValue>(&config)?;
@@ -1726,7 +1786,7 @@ async fn update_memory_settings_persists_and_updates_widget_config() -> Result<(
}
#[test]
fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
fn update_memory_settings_updates_all_installed_thread_memory_modes() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
@@ -1746,9 +1806,17 @@ fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
let mut app_server =
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
app.chat_widget.active_thread_id = Some(thread_id);
let parent_thread_id = app_server
.start_thread(&app.config)
.await?
.session
.thread_id;
let side_thread_id = app_server
.start_thread(&app.config)
.await?
.session
.thread_id;
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
Box::pin(app.update_memory_settings_with_app_server(
&mut app_server,
@@ -1763,11 +1831,13 @@ fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
)
.await
.expect("state db should initialize");
let memory_mode = state_db
.get_thread_memory_mode(thread_id)
.await
.expect("thread memory mode should be readable");
assert_eq!(memory_mode.as_deref(), Some("disabled"));
for thread_id in [parent_thread_id, side_thread_id] {
let memory_mode = state_db
.get_thread_memory_mode(thread_id)
.await
.expect("thread memory mode should be readable");
assert_eq!(memory_mode.as_deref(), Some("disabled"));
}
app_server.shutdown().await?;
Ok(())
+8 -1
View File
@@ -144,7 +144,7 @@ impl ChatWidget {
/// so the footer reflects it without waiting for the next mode switch.
/// Passing `None` resets to the Plan-mode preset default.
pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
self.config.plan_mode_reasoning_effort = effort.clone();
self.sync_plan_mode_reasoning_effort_config(effort.clone());
if self.collaboration_modes_enabled()
&& let Some(mask) = self.active_collaboration_mask.as_mut()
&& mask.mode == Some(ModeKind::Plan)
@@ -160,6 +160,13 @@ impl ChatWidget {
self.refresh_model_dependent_surfaces();
}
pub(crate) fn sync_plan_mode_reasoning_effort_config(
&mut self,
effort: Option<ReasoningEffortConfig>,
) {
self.config.plan_mode_reasoning_effort = effort;
}
/// Set the reasoning effort for the non-Plan collaboration mode.
///
/// Does not touch the active Plan mask — Plan reasoning is controlled