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:14:48 +00:00
committed by copyberry
parent 944d6fd1ba
commit 53ff712a48
23 changed files with 230 additions and 74 deletions

View File

@@ -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)
}
}
}
}

View File

@@ -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(),
});

View File

@@ -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(

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,

View File

@@ -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);

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) {

View File

@@ -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

View File

@@ -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 "

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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!

View File

@@ -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!

View File

@@ -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

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."),

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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