Remember Command Center grouping across launches (#50786)

## Why

Command Center previously reset to project grouping on startup, losing the user's selected grouping.

## What changed

Save grouping changes to `tui.agents_overview_grouping` in the client’s user configuration and restore the saved choice at startup. Support `project`, `status`, and `model`, with `project` as the default. Report save failures in Command Center while keeping the selected grouping active.

## Testing

Add tests for cycling through and persisting all grouping modes, preserving unrelated settings in a custom configuration file, displaying save errors, and restoring saved grouping during startup.

GitOrigin-RevId: fd56dfecb4b79cfa8439f54420ce58cc0ed66ec7
This commit is contained in:
Benjamin Carlsson
2026-10-04 04:25:31 +00:00
committed by copyberry
parent d0759639f2
commit acf9818fae
13 changed files with 235 additions and 12 deletions
+24
View File
@@ -67,6 +67,26 @@ const fn default_enabled() -> bool {
true
}
/// Last selected grouping in Agent Command Center.
#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum AgentsOverviewGrouping {
#[default]
Project,
Status,
Model,
}
impl AgentsOverviewGrouping {
pub const fn as_str(self) -> &'static str {
match self {
Self::Project => "project",
Self::Status => "status",
Self::Model => "model",
}
}
}
/// Preferred layout for the resume/fork session picker.
#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
@@ -932,6 +952,10 @@ pub struct Tui {
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
/// Last selected grouping in Agent Command Center.
#[serde(default)]
pub agents_overview_grouping: AgentsOverviewGrouping,
/// Working directory to use when resuming or forking a session.
/// When unset, prompt if the current and session directories differ.
#[serde(default)]
+18
View File
@@ -31,6 +31,15 @@
},
"type": "object"
},
"AgentsOverviewGrouping": {
"description": "Last selected grouping in Agent Command Center.",
"enum": [
"project",
"status",
"model"
],
"type": "string"
},
"AgentsToml": {
"additionalProperties": {
"$ref": "#/definitions/AgentRoleToml"
@@ -4487,6 +4496,15 @@
"additionalProperties": false,
"description": "Collection of settings that are specific to the TUI.",
"properties": {
"agents_overview_grouping": {
"allOf": [
{
"$ref": "#/definitions/AgentsOverviewGrouping"
}
],
"default": "project",
"description": "Last selected grouping in Agent Command Center."
},
"alternate_screen": {
"allOf": [
{
+2
View File
@@ -1280,6 +1280,7 @@ fn config_toml_deserializes_model_availability_nux() {
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
agents_overview_grouping: Default::default(),
resume_cwd: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
@@ -4422,6 +4423,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
agents_overview_grouping: Default::default(),
resume_cwd: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
+4
View File
@@ -848,6 +848,9 @@ pub struct Config {
/// Preferred layout for resume/fork session picker results.
pub tui_session_picker_view: SessionPickerViewMode,
/// Last selected grouping in Agent Command Center.
pub tui_agents_overview_grouping: codex_config::types::AgentsOverviewGrouping,
/// Working directory to use when resuming or forking a session.
/// When unset, prompt if the current and session directories differ.
pub tui_resume_cwd: Option<ResumeCwdMode>,
@@ -4571,6 +4574,7 @@ impl Config {
.as_ref()
.and_then(|t| t.session_picker_view)
.unwrap_or_default(),
tui_agents_overview_grouping: cfg.tui.as_ref().map(|t| t.agents_overview_grouping).unwrap_or_default(),
tui_resume_cwd: cfg.tui.as_ref().and_then(|t| t.resume_cwd),
terminal_resize_reflow,
tui_keymap: cfg
@@ -304,6 +304,7 @@ async fn new_config(
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
tui_keymap: TuiKeymap::default(),
tui_session_picker_view: SessionPickerViewMode::Dense,
tui_agents_overview_grouping: Default::default(),
tui_resume_cwd: None,
tui_vim_mode_default: false,
tui_question_esc_back: true,
@@ -1,16 +1,14 @@
//! Task grouping modes share model labels and group membership across rendering and navigation.
//! Command Center grouping, including restoration and client-local preference persistence.
//! The view owns the live choice; ordered app events save it through the existing config writer.
use super::super::App;
use super::super::agents_overview::AgentsOverviewState;
use super::AgentsOverviewGrouping;
use super::AgentsOverviewView;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
use codex_app_server_protocol::Thread;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(in super::super) enum AgentsOverviewGrouping {
#[default]
Project,
Status,
Model,
}
pub(super) fn model_name(thread: &Thread) -> &str {
thread
.model
@@ -37,3 +35,38 @@ impl AgentsOverviewView {
}
}
}
impl AgentsOverviewState {
pub(in super::super) fn new(grouping: codex_config::types::AgentsOverviewGrouping) -> Self {
let state = Self::default();
state
.view_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.grouping = grouping;
state
}
}
impl App {
pub(in super::super) async fn persist_agents_overview_grouping(
&mut self,
grouping: AgentsOverviewGrouping,
) {
self.local_settings.tui.agents_overview_grouping = grouping;
let edit = ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "agents_overview_grouping".to_string()],
value: grouping.as_str().into(),
};
if let Err(err) =
ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path())
.with_edits([edit])
.apply()
.await
{
self.add_agents_overview_error(format!(
"Failed to save Command Center grouping: {err}"
));
}
}
}
@@ -2944,6 +2944,66 @@ fn trust_fixture_folders(app: &mut App) {
#[path = "agents_overview_usage_tests.rs"]
mod usage;
#[tokio::test]
async fn overview_grouping_persists_across_config_reloads() -> Result<()> {
let (mut app, mut events, _ops) = crate::app::tests::make_test_app_with_channels().await;
let home = tempfile::tempdir()?;
let config_path = home.path().join("work.config.toml");
std::fs::write(&config_path, "[tui]\nanimations = false\n")?;
app.local_settings.user_config_path = AbsolutePathBuf::try_from(config_path.clone())?;
let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
for expected in [
AgentsOverviewGrouping::Status,
AgentsOverviewGrouping::Model,
AgentsOverviewGrouping::Project,
] {
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Char('g').into());
let event = std::iter::from_fn(|| events.try_recv().ok())
.find(|event| matches!(event, AppEvent::PersistAgentsOverviewGrouping(_)))
.expect("grouping change emits persistence event");
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
drop(view);
let mut loader = codex_config::LoaderOverrides::without_managed_config_for_tests();
loader.user_config_path = Some(AbsolutePathBuf::try_from(config_path.clone())?);
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.loader_overrides(loader)
.build()
.await?;
app.local_settings = crate::local_settings::LocalSettings::from(&config);
assert_eq!(app.local_settings.tui.agents_overview_grouping, expected);
assert!(!app.local_settings.tui.animations);
}
assert!(!home.path().join("config.toml").exists());
app.local_settings.user_config_path = AbsolutePathBuf::try_from(home.path())?;
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.agents_overview.view_state.lock().unwrap().grouping = AgentsOverviewGrouping::Status;
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::PersistAgentsOverviewGrouping(AgentsOverviewGrouping::Status),
))
.await?;
assert_eq!(
app.agents_overview.view_state.lock().unwrap().grouping,
AgentsOverviewGrouping::Status
);
assert_eq!(
app.local_settings.tui.agents_overview_grouping,
AgentsOverviewGrouping::Status
);
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 100)
.contains("Failed to save Command Center grouping")
);
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone() {
let mut app = make_test_app().await;
@@ -2969,6 +3029,10 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone(
matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewWorktree { cwd: Some(cwd) }) if cwd == target.cwd)
);
view.handle_key_event(KeyCode::Char('g').into());
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::PersistAgentsOverviewGrouping(_))
));
}
view.handle_key_event(KeyCode::Char('r').into());
for character in "nwogrxfha".chars() {
+3 -1
View File
@@ -7,7 +7,7 @@ pub(super) mod command_center;
#[path = "agents_overview_grouping.rs"]
mod grouping;
pub(super) use grouping::AgentsOverviewGrouping;
pub(super) use codex_config::types::AgentsOverviewGrouping;
use grouping::model_name;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
@@ -678,6 +678,8 @@ impl BottomPaneView for AgentsOverviewView {
AgentsOverviewGrouping::Status => AgentsOverviewGrouping::Model,
AgentsOverviewGrouping::Model => AgentsOverviewGrouping::Project,
};
self.app_event_tx
.send(AppEvent::PersistAgentsOverviewGrouping(state.grouping));
return;
}
if self.agents_keymap.new_task.is_pressed(key) {
+3
View File
@@ -3242,6 +3242,9 @@ impl App {
AppEvent::TerminalTitleSetupCancelled => {
self.chat_widget.cancel_terminal_title_setup();
}
AppEvent::PersistAgentsOverviewGrouping(grouping) => {
self.persist_agents_overview_grouping(grouping).await;
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path())
+3 -1
View File
@@ -829,6 +829,8 @@ See the Codex keymap documentation for supported actions and examples."
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let agents_overview =
agents_overview::AgentsOverviewState::new(local_settings.tui.agents_overview_grouping);
let mut app = Self {
feature_write_lock: Arc::default(),
model_catalog,
@@ -904,7 +906,7 @@ See the Codex keymap documentation for supported actions and examples."
pending_thread_titles: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
agents_overview: Default::default(),
agents_overview,
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
@@ -11,6 +11,7 @@ async fn run_startup_for_test(
config: Config,
bootstrap: AppServerBootstrap,
selection: SessionSelection,
app_server_target: AppServerTarget,
) -> Result<AppExitInfo> {
App::run(
tui,
@@ -27,7 +28,7 @@ async fn run_startup_for_test(
codex_feedback::CodexFeedback::new(),
/*is_first_run*/ false,
/*should_prompt_windows_sandbox_nux_at_startup*/ false,
AppServerTarget::Embedded,
app_server_target,
/*state_db*/ None,
Arc::new(EnvironmentManager::default_for_tests()),
Duration::ZERO,
@@ -41,6 +42,68 @@ async fn run_startup_for_test(
.await
}
#[tokio::test]
async fn agents_overview_startup_restores_saved_grouping() -> Result<()> {
let home = tempdir()?;
std::fs::write(
home.path().join("config.toml"),
"[tui]\nagents_overview_grouping = \"status\"\nanimations = false\n",
)?;
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.build()
.await?;
let (mut server, requests, proxy) = start_recording_remote_app_server(&config).await?;
let bootstrap = server.bootstrap(&config).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events();
let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?;
let mut run = Box::pin(run_startup_for_test(
&mut tui,
server,
config,
bootstrap,
SessionSelection::AgentsOverview,
AppServerTarget::LocalDaemon {
allow_embedded_fallback: true,
endpoint,
},
));
tokio::time::timeout(Duration::from_secs(/*secs*/ 15), async {
loop {
if !recorded_params(&requests, "thread/list").is_empty() {
return Ok::<(), color_eyre::eyre::Report>(());
}
tokio::select! {
_ = &mut run => return Ok(()),
() = tokio::time::sleep(Duration::from_millis(/*millis*/ 20)) => {}
}
}
})
.await??;
drop(run);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer
.content
.chunks(buffer.area.width as usize)
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let grouping_header = rendered
.lines()
.find(|line| line.contains("Group:"))
.expect("production startup renders Command Center grouping")
.trim();
insta::assert_snapshot!(grouping_header, @"Agent command center Group: Status g");
proxy.abort();
Ok(())
}
#[tokio::test]
async fn cli_fork_omits_implicit_model_and_effort() -> Result<()> {
let home = tempdir()?;
@@ -113,6 +176,7 @@ async fn cli_fork_omits_implicit_model_and_effort() -> Result<()> {
cwd: None,
history_mode: None,
}),
AppServerTarget::Embedded,
));
tokio::time::timeout(Duration::from_secs(/*secs*/ 15), async {
loop {
@@ -154,6 +218,7 @@ async fn run_until_thread_start(
config,
bootstrap,
SessionSelection::StartFresh,
AppServerTarget::Embedded,
));
tokio::time::timeout(Duration::from_secs(/*secs*/ 15), async {
loop {
@@ -598,6 +663,7 @@ async fn startup_read_failure_exits_before_thread_creation() -> Result<()> {
config,
bootstrap,
SessionSelection::StartFresh,
AppServerTarget::Embedded,
)
.await;
insta::assert_snapshot!(result.expect_err("startup read must fail").to_string(), @"config/read failed in TUI");
+3
View File
@@ -1595,6 +1595,9 @@ pub(crate) enum AppEvent {
/// Dismiss the terminal-title setup UI without changing config.
TerminalTitleSetupCancelled,
/// Remember the Command Center grouping across launches.
PersistAgentsOverviewGrouping(codex_config::types::AgentsOverviewGrouping),
/// Save the transcript renderer preference for the next launch only.
FullscreenTranscriptSelected {
enabled: bool,
+1
View File
@@ -79,6 +79,7 @@ impl LocalSettings {
pet: config.tui_pet.clone(),
pet_anchor: config.tui_pet_anchor,
session_picker_view: Some(config.tui_session_picker_view),
agents_overview_grouping: config.tui_agents_overview_grouping,
resume_cwd: config.tui_resume_cwd,
keymap: config.tui_keymap.clone(),
model_availability_nux: config.model_availability_nux.clone(),