mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
fix(tui): synchronize shared pane state
This commit is contained in:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
@@ -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 "
|
||||
+16
@@ -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 "
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user