Add model grouping to the agent command center (#44957)

## What changed

- Cycle task grouping through project, status, and model with `Ctrl+S`, and show the active grouping in the footer.
- Group tasks by model with the most recently updated tasks first within each group. Use `Unknown` for missing or empty model names.
- Display the model in task details and refresh it when thread settings change.
- In model grouping, dispatch new tasks without inheriting the selected task's working directory, matching status grouping.

## Testing

Add coverage for model grouping, selection preservation, navigation, and new-task dispatch across grouping modes. Update snapshots for model details and grouping hints.

GitOrigin-RevId: 429cf61c1e7438ba3b2f989d3ed14e12545856f7
This commit is contained in:
Eric Traut
2026-09-12 02:24:02 +00:00
committed by copyberry
parent 944d6fd1ba
commit 53ff712a48
23 changed files with 230 additions and 74 deletions
@@ -0,0 +1,39 @@
//! Task grouping modes share model labels and group membership across rendering and navigation.
use super::AgentsOverviewView;
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
.as_deref()
.filter(|model| !model.is_empty())
.unwrap_or("Unknown")
}
impl AgentsOverviewView {
pub(super) fn same_group(
&self,
grouping: AgentsOverviewGrouping,
left: usize,
right: usize,
) -> bool {
match grouping {
AgentsOverviewGrouping::Project => {
self.project_groups[left].key == self.project_groups[right].key
}
AgentsOverviewGrouping::Status => self.rows[left].group == self.rows[right].group,
AgentsOverviewGrouping::Model => {
model_name(&self.rows[left].thread) == model_name(&self.rows[right].thread)
}
}
}
}
@@ -12,7 +12,7 @@ impl AgentsOverviewView {
pub(super) fn handle_composer_key(&mut self, key: KeyEvent) {
let mut state = self.state();
let offline = state.connection_notice.is_some();
let status_grouping = state.status_grouping;
let grouping = state.grouping;
if !offline
&& crate::key_hint::plain(KeyCode::Right).is_press(key)
&& state
@@ -78,7 +78,7 @@ impl AgentsOverviewView {
self.app_event_tx
.send(AppEvent::DispatchAgentsOverviewTask {
prompt,
cwd: (!status_grouping)
cwd: (grouping == AgentsOverviewGrouping::Project)
.then(|| self.selected_row().map(|row| row.thread.cwd.clone()))
.flatten(),
});
@@ -95,7 +95,11 @@ impl AgentsOverviewView {
add_hint(
self.agents_keymap
.primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping),
"group",
match self.state().grouping {
AgentsOverviewGrouping::Project => "group: project",
AgentsOverviewGrouping::Status => "group: status",
AgentsOverviewGrouping::Model => "group: model",
},
true,
);
add_hint(
+88 -3
View File
@@ -1,3 +1,4 @@
use super::super::agents_overview_view::AgentsOverviewGrouping;
use super::*;
#[tokio::test]
@@ -1403,6 +1404,64 @@ async fn worktrees_overview_grouping_requires_feature() {
}
}
#[tokio::test]
async fn overview_model_grouping_shows_details_and_preserves_selection() {
let mut app = make_test_app().await;
let threads = [
("Older task", Some("model-a"), 1),
("Other model", Some("model-b"), 2),
("Recent task", Some("model-a"), 3),
("Legacy task", None, 4),
]
.into_iter()
.map(|(name, model, index)| {
let mut thread = overview_thread(
ThreadId::from_u128(index),
/*parent_thread_id*/ None,
name,
ThreadStatus::Idle,
);
thread.model = model.map(str::to_string);
thread.updated_at = index as i64;
thread
})
.collect();
let selected = ThreadId::from_u128(/*value*/ 1);
let mut view = app.agents_overview_view(threads, Some(selected));
view.handle_key_event(KeyCode::Esc.into());
for _ in 0..2 {
view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
}
assert_eq!(
app.agents_overview.view_state.lock().unwrap().grouping,
AgentsOverviewGrouping::Model
);
assert_eq!(
view.rows[view.selected_index().unwrap()].thread_id,
selected
);
// Within a model, newer tasks come first; navigation then crosses model groups.
for (key, expected) in [
(KeyCode::Up, 3),
(KeyCode::Down, 1),
(KeyCode::Down, 2),
(KeyCode::Down, 4),
] {
view.handle_key_event(key.into());
assert_eq!(
view.rows[view.selected_index().unwrap()].thread_id,
ThreadId::from_u128(expected)
);
}
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::assert_snapshot!(
"agents_overview_model_grouping",
render_bottom_popup(&app.chat_widget, /*width*/ 100)
.replace(&test_path_display("/tmp/project"), "/tmp/project")
.replace("fwd del", "del")
);
}
#[tokio::test]
async fn shared_overview_shows_only_root_sessions() {
assert_eq!(
@@ -1466,12 +1525,21 @@ async fn shared_overview_shows_only_root_sessions() {
Arc::clone(&app.agents_overview.view_state),
);
let state = &app.agents_overview.view_state;
assert!(!state.lock().unwrap().status_grouping);
assert_eq!(
state.lock().unwrap().grouping,
AgentsOverviewGrouping::Project
);
action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(state.lock().unwrap().status_grouping);
assert_eq!(
state.lock().unwrap().grouping,
AgentsOverviewGrouping::Status
);
app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
assert!(state.lock().unwrap().status_grouping);
assert_eq!(
state.lock().unwrap().grouping,
AgentsOverviewGrouping::Status
);
assert!(
action_view.handle_paste("Use \u{1b}[31mthe\u{1b}[0m current project\u{7}".to_string())
);
@@ -1483,6 +1551,23 @@ async fn shared_overview_shows_only_root_sessions() {
));
action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert_eq!(
state.lock().unwrap().grouping,
AgentsOverviewGrouping::Model
);
assert!(action_view.handle_paste("Use the default project".to_string()));
action_view.handle_key_event(KeyCode::Enter.into());
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None })
if prompt.text == "Use the default project"
));
action_view.handle_key_event(KeyCode::Esc.into());
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert_eq!(
state.lock().unwrap().grouping,
AgentsOverviewGrouping::Project
);
assert!(action_view.handle_paste("Fix the flaky tests after all retries complete".to_string()));
let area = ratatui::layout::Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
@@ -86,6 +86,7 @@ impl App {
ServerNotification::ThreadSettingsUpdated(settings) => {
if let Some(thread) = thread {
thread.cwd.clone_from(&settings.thread_settings.cwd);
thread.model = Some(settings.thread_settings.model.clone());
thread
.model_provider
.clone_from(&settings.thread_settings.model_provider);
+38 -36
View File
@@ -1,11 +1,16 @@
//! Dashboard for inspecting and managing the TUI's retained daemon tasks.
//! The shared view state retains the new-task editor across metadata refreshes.
#[path = "agents_overview_grouping.rs"]
mod grouping;
#[path = "agents_overview_input.rs"]
mod input;
#[path = "agents_overview_render.rs"]
mod render;
pub(super) use grouping::AgentsOverviewGrouping;
use grouping::model_name;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::agents_overview_details::AgentsOverviewDetails;
use crate::app_event::AgentsOverviewAction;
@@ -138,7 +143,7 @@ pub(super) struct AgentsOverviewViewState {
pub(super) server_version_notice: Option<String>,
search: String,
searching: bool,
pub(super) status_grouping: bool,
pub(super) grouping: AgentsOverviewGrouping,
pub(super) renaming: bool,
// The picker can finish this retained view when it selects the already active session.
pub(super) completion: Option<ViewCompletion>,
@@ -281,13 +286,20 @@ impl AgentsOverviewView {
(search.is_empty() || searchable.contains(&search)).then_some(index)
})
.collect::<Vec<_>>();
if !state.status_grouping {
visible.sort_by_key(|index| {
match state.grouping {
AgentsOverviewGrouping::Project => visible.sort_by_key(|index| {
(
&self.project_groups[*index].key,
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
});
}),
AgentsOverviewGrouping::Status => {}
AgentsOverviewGrouping::Model => visible.sort_by_key(|index| {
(
model_name(&self.rows[*index].thread),
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
}),
}
visible
}
@@ -367,11 +379,7 @@ impl AgentsOverviewView {
fn render_rows(&self, area: Rect, buf: &mut Buffer) {
let mut offset = 0;
let mut previous_group_index: Option<usize> = None;
let project_grouping = !self
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.status_grouping;
let grouping = self.state().grouping;
let visible = self.visible_indices();
let mut first = visible
.iter()
@@ -381,13 +389,7 @@ impl AgentsOverviewView {
while first > 0 {
let previous_index = visible[first - 1];
let current_index = visible[first];
let previous = &self.rows[previous_index];
let current = &self.rows[current_index];
let group_changed = if project_grouping {
self.project_groups[previous_index].key != self.project_groups[current_index].key
} else {
previous.group != current.group
};
let group_changed = !self.same_group(grouping, previous_index, current_index);
let added_height = 1 + 2 * u16::from(group_changed);
if height + added_height > area.height {
break;
@@ -400,18 +402,15 @@ impl AgentsOverviewView {
break;
}
let row = &self.rows[index];
let group = if project_grouping {
self.project_groups[index].heading.display().to_string()
} else {
row.group.label().to_string()
};
let group_changed = previous_group_index.is_none_or(|previous_index| {
if project_grouping {
self.project_groups[previous_index].key != self.project_groups[index].key
} else {
self.rows[previous_index].group != row.group
let group = match grouping {
AgentsOverviewGrouping::Project => {
self.project_groups[index].heading.display().to_string()
}
});
AgentsOverviewGrouping::Status => row.group.label().to_string(),
AgentsOverviewGrouping::Model => model_name(&row.thread).to_string(),
};
let group_changed = previous_group_index
.is_none_or(|previous_index| !self.same_group(grouping, previous_index, index));
if group_changed {
offset += u16::from(previous_group_index.is_some());
if offset >= area.height {
@@ -421,13 +420,8 @@ impl AgentsOverviewView {
.rows
.iter()
.enumerate()
.filter(|(candidate_index, candidate)| {
if project_grouping {
self.project_groups[*candidate_index].key
== self.project_groups[index].key
} else {
candidate.group == row.group
}
.filter(|(candidate_index, _)| {
self.same_group(grouping, *candidate_index, index)
})
.count();
Line::from(vec![group.clone().bold(), format!(" {count}").dim()])
@@ -453,7 +447,7 @@ impl AgentsOverviewView {
Span::styled(display_title(&row.thread), self.title_style(row.thread_id)),
current.dim(),
];
if project_grouping {
if grouping != AgentsOverviewGrouping::Status {
spans.extend([" ".into(), status.dim()]);
}
Line::from(spans).render(Rect::new(area.x, area.y + offset, area.width, 1), buf);
@@ -481,6 +475,10 @@ impl AgentsOverviewView {
Line::default(),
Line::from("Project".dim()),
Line::from(row.thread.cwd.display().to_string()),
Line::from(vec![
"Model: ".dim(),
model_name(&row.thread).to_string().into(),
]),
];
if let Some(branch) = row
.thread
@@ -692,7 +690,11 @@ impl BottomPaneView for AgentsOverviewView {
}
if self.agents_keymap.toggle_grouping.is_pressed(key) {
let mut state = self.state();
state.status_grouping = !state.status_grouping;
state.grouping = match state.grouping {
AgentsOverviewGrouping::Project => AgentsOverviewGrouping::Status,
AgentsOverviewGrouping::Status => AgentsOverviewGrouping::Model,
AgentsOverviewGrouping::Model => AgentsOverviewGrouping::Project,
};
return;
}
if self.agents_keymap.new_task.is_pressed(key) {
@@ -1,6 +1,6 @@
---
source: tui/src/app/agents_overview_actions_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 48)"
expression: "render_bottom_popup(&app.chat_widget,\n48).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")"
---
Agent command center
0 need input 0 working 1 ready
@@ -17,12 +17,12 @@ expression: "render_bottom_popup(&app.chat_widget, 48)"
New task
› Describe a new task
↑↓ navigate ctrl+o resume → open
ctrl+n new task ctrl+f search ctrl+s group
ctrl+r rename ctrl+x stop f5 f8 hide
f5 f6 archive f5 f7 delete esc back
ctrl+n new task ctrl+f search
ctrl+s group: project ctrl+r rename
ctrl+x stop f5 f8 hide f5 f6 archive
f5 f7 delete esc back
@@ -22,6 +22,7 @@ expression: "cached.replace(&format!(\"{project} 1\"),\n&format!(\"/tmp/project
" │ "
" │ Project "
" │ /tmp/project "
" │ Model: Unknown "
" │ "
" │ Prompt "
" │ Review parser and token handling. "
@@ -36,7 +37,6 @@ expression: "cached.replace(&format!(\"{project} 1\"),\n&format!(\"/tmp/project
" │ "
" │ "
" │ "
" │ "
" New task "
" "
"› Describe a new task "
@@ -12,11 +12,11 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│ Last message
│ let explanation = "A long code line
│ should wrap inside the task details
│ panel.";
│ …
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ long prompt long prompt long prompt
@@ -12,11 +12,11 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│ Last message
│ Check Result
│ ━━━━━━━━ ━━━━━━━━
│ Parser Fixed
│ …
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ long prompt long prompt long prompt
@@ -0,0 +1,28 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\n100).replace(&test_path_display(\"/tmp/project\"),\n\"/tmp/project\").replace(\"fwd del\", \"del\")"
---
Agent command center
0 need input 0 working 4 ready
────────────────────────────────────────────────────────────────────────────────────────────────
Unknown 1 │ Task details
› ○ Legacy task Ready │
│ Legacy task
model-a 2 │ ○ Ready
○ Recent task Ready │
○ Older task Ready │ Project
│ /tmp/project
model-b 1 │ Model: Unknown
○ Other model Ready │
│ Prompt
│ Legacy task
│
│
│
│
New task
› Describe a new task
↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group: model ctrl+r rename
ctrl+x stop ctrl+w hide ctrl+e archive del delete esc back
@@ -1,6 +1,5 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 314
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"fwd del\", \"del\")"
---
Agent command center
@@ -13,6 +12,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ Second task
@@ -20,10 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│
│
│
New task
› Unsent draft
↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group ctrl+r rename
ctrl+x stop ctrl+w hide ctrl+e archive del delete esc back
↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group: project
ctrl+r rename ctrl+x stop ctrl+w hide ctrl+e archive del delete esc back
@@ -2,4 +2,4 @@
source: tui/src/app/agents_overview_tests.rs
expression: "rendered.lines().find(|line| line.contains(\"open\")).unwrap()"
---
↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group ctrl+r rename
↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group: project
@@ -12,6 +12,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ Selected task
@@ -20,7 +21,6 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│
│
│
│
New task
› Keep this task draft!
@@ -12,6 +12,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ Selected task
@@ -20,7 +21,6 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│
│
│
│
New task
› Keep this task draft!
@@ -12,6 +12,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ New task
@@ -20,7 +21,6 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│
│
│
│
New task
› Describe a new task
+1 -1
View File
@@ -221,7 +221,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("agents", "Agents", "archive", "Archive the selected task and its child agents."),
action("agents", "Agents", "delete", "Permanently delete the selected task and its child agents."),
action("agents", "Agents", "hide", "Hide the selected task until explicitly resumed."),
action("agents", "Agents", "toggle_grouping", "Group tasks by status or project."),
action("agents", "Agents", "toggle_grouping", "Cycle task grouping by project, status, or model."),
action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."),
action("approval", "Approval", "open_thread", "Open the approval source thread when available."),
action("approval", "Approval", "approve", "Approve the primary option."),
@@ -16,10 +16,10 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ Build the dashboard
│
New task
› First line
@@ -13,11 +13,11 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Agent: Check dependencies
│ Which dependency version should I use?
│ Open task to review.
│ Waiting for approval.
│ Waiting for your response.
│ …
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ Repair authentication
@@ -1,7 +1,6 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 2041
expression: "render_bottom_popup(&app.chat_widget, 48)"
expression: rendered
---
Agent command center
0 need input 0 working 0 ready
@@ -18,12 +17,12 @@ expression: "render_bottom_popup(&app.chat_widget, 48)"
New task
› Describe a new task
↑↓ navigate ctrl+o resume → open
ctrl+n new task ctrl+f search ctrl+s group
ctrl+r rename ctrl+x stop ctrl+w hide
ctrl+e archive del delete esc back
ctrl+n new task ctrl+f search
ctrl+s group: project ctrl+r rename
ctrl+x stop ctrl+w hide ctrl+e archive
del delete esc back
@@ -12,11 +12,11 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│ Last message
│ Found the regression in the parser.
│ Found the regression in the parser.
│ …
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ Investigate parser
@@ -15,12 +15,12 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│ Project
│ /tmp/project
│ Model: Unknown
│
│ Prompt
│ No prompt available.
│
│
│
New task
› Keep this draft
@@ -1,6 +1,5 @@
---
source: tui/src/keymap_setup.rs
assertion_line: 1039
expression: agents
---
Resume | ctrl-o | Agents resume Resume Open the session resume picker. ctrl-o Default
@@ -11,4 +10,4 @@ Stop | ctrl-x | Agents stop Stop Stop the selected running task. ctrl-x Default
Archive | ctrl-e | Agents archive Archive Archive the selected task and its child agents. ctrl-e Default
Delete | delete | Agents delete Delete Permanently delete the selected task and its child agents. delete Default
Hide | ctrl-w | Agents hide Hide Hide the selected task until explicitly resumed. ctrl-w Default
Toggle Grouping | ctrl-s | Agents toggle_grouping Toggle Grouping Group tasks by status or project. ctrl-s Default
Toggle Grouping | ctrl-s | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, status, or model. ctrl-s Default