mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
committed by
copyberry
parent
d0759639f2
commit
acf9818fae
@@ -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)]
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user