mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Separate directory and repository scopes in the TUI (#56308)
## Why The `Cwd` session filter included sessions from linked worktrees, leaving no way to restrict the picker to the current directory. Project grouping also combined tasks across linked worktrees. ## What changed - Keep `Cwd` filtering and directory-scoped `resume --last` and `fork --last` lookups limited to the current directory. - Add an explicit `Repo` picker filter when multiple local worktrees are available, including sessions from corresponding directories across linked worktrees. Cycle between `Cwd`, `Repo`, and `All` in either direction. - Add `Directory` grouping to the agent command center alongside `Project`, `Status`, and `Model`. Keep this choice in memory without writing a configuration value older clients cannot read. ## Testing Extend coverage for directory and repository filtering, pagination scope, linked-worktree grouping, directory aliases, grouping persistence, and adjacent selection after task removal. Update picker and command center snapshots. GitOrigin-RevId: 59603c9c5136db43124102b3277d1d1dab36bb87
This commit is contained in:
committed by
copyberry
parent
75a4146411
commit
334527705c
@@ -137,6 +137,7 @@ impl Renderable for AgentsOverviewView {
|
||||
};
|
||||
let grouping = match state.grouping {
|
||||
AgentsOverviewGrouping::Project => "Project",
|
||||
AgentsOverviewGrouping::Directory => "Directory",
|
||||
AgentsOverviewGrouping::Status => "Status",
|
||||
AgentsOverviewGrouping::Model => "Model",
|
||||
};
|
||||
|
||||
@@ -150,6 +150,10 @@ impl AgentsOverviewView {
|
||||
/*max_width*/ None,
|
||||
)
|
||||
}
|
||||
AgentsOverviewGrouping::Directory => format_directory_display(
|
||||
self.rows[index].thread.cwd.as_path(),
|
||||
/*max_width*/ None,
|
||||
),
|
||||
AgentsOverviewGrouping::Status => task.group.label().to_owned(),
|
||||
AgentsOverviewGrouping::Model => model_name(&task.thread).to_owned(),
|
||||
}
|
||||
@@ -168,8 +172,10 @@ impl AgentsOverviewView {
|
||||
} else {
|
||||
format!("{count} of {total}")
|
||||
};
|
||||
let group = if state.grouping == AgentsOverviewGrouping::Project
|
||||
&& !self.is_pinned(index)
|
||||
let group = if matches!(
|
||||
state.grouping,
|
||||
AgentsOverviewGrouping::Project | AgentsOverviewGrouping::Directory
|
||||
) && !self.is_pinned(index)
|
||||
{
|
||||
crate::text_formatting::center_truncate_path(
|
||||
&group,
|
||||
|
||||
@@ -238,10 +238,20 @@ async fn archiving_selects_the_next_displayed_task() -> Result<()> {
|
||||
let expected = match (grouping, filtered) {
|
||||
(AgentsOverviewGrouping::Status, false) => vec![3, 2, 1, 4],
|
||||
(AgentsOverviewGrouping::Status, true) => vec![3, 1, 4],
|
||||
(AgentsOverviewGrouping::Project | AgentsOverviewGrouping::Model, false) => {
|
||||
(
|
||||
AgentsOverviewGrouping::Project
|
||||
| AgentsOverviewGrouping::Directory
|
||||
| AgentsOverviewGrouping::Model,
|
||||
false,
|
||||
) => {
|
||||
vec![3, 4, 2, 1]
|
||||
}
|
||||
(AgentsOverviewGrouping::Project | AgentsOverviewGrouping::Model, true) => {
|
||||
(
|
||||
AgentsOverviewGrouping::Project
|
||||
| AgentsOverviewGrouping::Directory
|
||||
| AgentsOverviewGrouping::Model,
|
||||
true,
|
||||
) => {
|
||||
vec![3, 4, 1]
|
||||
}
|
||||
};
|
||||
@@ -311,6 +321,7 @@ async fn archiving_selects_the_next_displayed_task() -> Result<()> {
|
||||
async fn external_removals_preserve_adjacent_selection() {
|
||||
for grouping in [
|
||||
AgentsOverviewGrouping::Project,
|
||||
AgentsOverviewGrouping::Directory,
|
||||
AgentsOverviewGrouping::Status,
|
||||
AgentsOverviewGrouping::Model,
|
||||
] {
|
||||
@@ -362,7 +373,9 @@ async fn external_removals_preserve_adjacent_selection() {
|
||||
);
|
||||
let expected = match grouping {
|
||||
AgentsOverviewGrouping::Status => [ids[0], ids[3]],
|
||||
AgentsOverviewGrouping::Project | AgentsOverviewGrouping::Model => [ids[3], ids[0]],
|
||||
AgentsOverviewGrouping::Project
|
||||
| AgentsOverviewGrouping::Directory
|
||||
| AgentsOverviewGrouping::Model => [ids[3], ids[0]],
|
||||
};
|
||||
let archive = ServerNotification::ThreadArchived(ThreadArchivedNotification {
|
||||
thread_id: ids[2].to_string(),
|
||||
@@ -438,6 +451,7 @@ async fn hiding_tasks_keeps_selection_adjacent_in_display_order() -> Result<()>
|
||||
let mut selections = Vec::new();
|
||||
for grouping in [
|
||||
AgentsOverviewGrouping::Project,
|
||||
AgentsOverviewGrouping::Directory,
|
||||
AgentsOverviewGrouping::Status,
|
||||
AgentsOverviewGrouping::Model,
|
||||
] {
|
||||
@@ -470,10 +484,20 @@ async fn hiding_tasks_keeps_selection_adjacent_in_display_order() -> Result<()>
|
||||
// The preceding filtered case retains Other 2; rediscovered tasks append.
|
||||
(AgentsOverviewGrouping::Status, false) => vec![3, 1, 4, 2],
|
||||
(AgentsOverviewGrouping::Status, true) => vec![3, 1, 4],
|
||||
(AgentsOverviewGrouping::Project | AgentsOverviewGrouping::Model, false) => {
|
||||
(
|
||||
AgentsOverviewGrouping::Project
|
||||
| AgentsOverviewGrouping::Directory
|
||||
| AgentsOverviewGrouping::Model,
|
||||
false,
|
||||
) => {
|
||||
vec![3, 4, 2, 1]
|
||||
}
|
||||
(AgentsOverviewGrouping::Project | AgentsOverviewGrouping::Model, true) => {
|
||||
(
|
||||
AgentsOverviewGrouping::Project
|
||||
| AgentsOverviewGrouping::Directory
|
||||
| AgentsOverviewGrouping::Model,
|
||||
true,
|
||||
) => {
|
||||
vec![3, 4, 1]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
//! 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.
|
||||
//! Directory is view-local so selecting it never writes a value that older clients reject.
|
||||
|
||||
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, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(in crate::app) enum AgentsOverviewGrouping {
|
||||
#[default]
|
||||
Project,
|
||||
Directory,
|
||||
Status,
|
||||
Model,
|
||||
}
|
||||
|
||||
impl From<codex_config::types::AgentsOverviewGrouping> for AgentsOverviewGrouping {
|
||||
fn from(grouping: codex_config::types::AgentsOverviewGrouping) -> Self {
|
||||
match grouping {
|
||||
codex_config::types::AgentsOverviewGrouping::Project => Self::Project,
|
||||
codex_config::types::AgentsOverviewGrouping::Status => Self::Status,
|
||||
codex_config::types::AgentsOverviewGrouping::Model => Self::Model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentsOverviewGrouping {
|
||||
pub(super) fn persisted(self) -> Option<codex_config::types::AgentsOverviewGrouping> {
|
||||
match self {
|
||||
Self::Project => Some(codex_config::types::AgentsOverviewGrouping::Project),
|
||||
Self::Directory => None,
|
||||
Self::Status => Some(codex_config::types::AgentsOverviewGrouping::Status),
|
||||
Self::Model => Some(codex_config::types::AgentsOverviewGrouping::Model),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn model_name(thread: &Thread) -> &str {
|
||||
thread
|
||||
.model
|
||||
@@ -56,6 +85,12 @@ impl AgentsOverviewView {
|
||||
std::cmp::Reverse(self.rows[*index].thread.updated_at),
|
||||
)
|
||||
}),
|
||||
AgentsOverviewGrouping::Directory => visible.sort_by_key(|index| {
|
||||
(
|
||||
&self.directory_group_keys[*index],
|
||||
std::cmp::Reverse(self.rows[*index].thread.updated_at),
|
||||
)
|
||||
}),
|
||||
AgentsOverviewGrouping::Status => {}
|
||||
AgentsOverviewGrouping::Model => visible.sort_by_key(|index| {
|
||||
(
|
||||
@@ -81,6 +116,9 @@ impl AgentsOverviewView {
|
||||
AgentsOverviewGrouping::Project => {
|
||||
self.project_groups[left].key == self.project_groups[right].key
|
||||
}
|
||||
AgentsOverviewGrouping::Directory => {
|
||||
self.directory_group_keys[left] == self.directory_group_keys[right]
|
||||
}
|
||||
AgentsOverviewGrouping::Status => self.rows[left].group == self.rows[right].group,
|
||||
AgentsOverviewGrouping::Model => {
|
||||
model_name(&self.rows[left].thread) == model_name(&self.rows[right].thread)
|
||||
@@ -96,7 +134,7 @@ impl AgentsOverviewState {
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.grouping = grouping;
|
||||
.grouping = grouping.into();
|
||||
state
|
||||
}
|
||||
}
|
||||
@@ -104,7 +142,7 @@ impl AgentsOverviewState {
|
||||
impl App {
|
||||
pub(in super::super) async fn persist_agents_overview_grouping(
|
||||
&mut self,
|
||||
grouping: AgentsOverviewGrouping,
|
||||
grouping: codex_config::types::AgentsOverviewGrouping,
|
||||
) {
|
||||
self.local_settings.tui.agents_overview_grouping = grouping;
|
||||
let edit = ConfigEdit::SetPath {
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::super::agents_overview_view::AgentsOverviewGrouping;
|
||||
use super::*;
|
||||
use crate::app::tests::make_test_app_with_channels;
|
||||
use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot;
|
||||
use codex_config::types::AgentsOverviewGrouping as PersistedAgentsOverviewGrouping;
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_worktree_creation_busy_state() {
|
||||
@@ -1132,16 +1133,18 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktrees_overview_groups_linked_checkouts() {
|
||||
async fn worktrees_overview_groups_by_project_or_directory() {
|
||||
let mut app = make_test_app().await;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let primary = root.path().join("primary");
|
||||
let linked = root.path().join("linked");
|
||||
let linked_subdir = linked.join("subdir/child");
|
||||
let primary_subdir = primary.join("shared/child");
|
||||
let linked_subdir = linked.join("shared/child");
|
||||
let nested = primary.join("subdir");
|
||||
let nested_child = nested.join("child");
|
||||
let admin = primary.join(".git/worktrees/linked");
|
||||
std::fs::create_dir_all(&admin).unwrap();
|
||||
std::fs::create_dir_all(&primary_subdir).unwrap();
|
||||
std::fs::create_dir_all(&linked_subdir).unwrap();
|
||||
std::fs::create_dir_all(nested.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(&nested_child).unwrap();
|
||||
@@ -1155,48 +1158,131 @@ async fn worktrees_overview_groups_linked_checkouts() {
|
||||
.unwrap();
|
||||
std::fs::write(linked.join(".git"), format!("gitdir: {}", admin.display())).unwrap();
|
||||
let primary = dunce::canonicalize(primary).unwrap();
|
||||
let threads = [primary.clone(), linked, linked_subdir, nested_child]
|
||||
.into_iter()
|
||||
.map(|cwd| {
|
||||
let mut thread = overview_thread(
|
||||
ThreadId::new(),
|
||||
/*parent_thread_id*/ None,
|
||||
"Task",
|
||||
ThreadStatus::Idle,
|
||||
);
|
||||
thread.cwd = AbsolutePathBuf::from_absolute_path(cwd).unwrap();
|
||||
thread
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let headings = [primary.join(""), primary.join("subdir").join("child")].map(|path| {
|
||||
crate::text_formatting::center_truncate_path(
|
||||
&crate::status::format_directory_display(&path, /*max_width*/ None),
|
||||
/*max_width*/ 64,
|
||||
let primary_subdir = dunce::canonicalize(primary_subdir).unwrap();
|
||||
let linked = dunce::canonicalize(linked).unwrap();
|
||||
let linked_subdir = dunce::canonicalize(linked_subdir).unwrap();
|
||||
let nested_child = dunce::canonicalize(nested_child).unwrap();
|
||||
let threads = [
|
||||
("Primary README", primary.clone()),
|
||||
("Primary tree", primary.clone()),
|
||||
("Linked README", linked.clone()),
|
||||
("Linked tree", linked.clone()),
|
||||
("Primary child", primary_subdir.clone()),
|
||||
("Linked child", linked_subdir.clone()),
|
||||
("Nested repository", nested_child.clone()),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(title, cwd)| {
|
||||
let mut thread = overview_thread(
|
||||
ThreadId::new(),
|
||||
/*parent_thread_id*/ None,
|
||||
title,
|
||||
ThreadStatus::Idle,
|
||||
);
|
||||
thread.cwd = AbsolutePathBuf::from_absolute_path(cwd).unwrap();
|
||||
thread
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let directory_headings = [
|
||||
(&primary, "/tmp/worktree-root/primary"),
|
||||
(&linked, "/tmp/worktree-root/linked"),
|
||||
(&primary_subdir, "/tmp/worktree-root/primary/shared/child"),
|
||||
(&linked_subdir, "/tmp/worktree-root/linked/shared/child"),
|
||||
(&nested_child, "/tmp/worktree-root/primary/subdir/child"),
|
||||
]
|
||||
.map(|(path, stable)| {
|
||||
(
|
||||
crate::text_formatting::center_truncate_path(
|
||||
&crate::status::format_directory_display(path, /*max_width*/ None),
|
||||
/*max_width*/ 64,
|
||||
),
|
||||
stable,
|
||||
)
|
||||
});
|
||||
let display_heading = &headings[0];
|
||||
let grouped_heading = format!("{display_heading} 2");
|
||||
let width = 160;
|
||||
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let popup = render_bottom_popup(&app.chat_widget, width);
|
||||
assert!(popup.contains(&grouped_heading), "{popup}");
|
||||
let grouping = popup
|
||||
let project_popup = render_bottom_popup(&app.chat_widget, width);
|
||||
let project_root = primary.join("");
|
||||
let project_headings = [
|
||||
(&project_root, "/tmp/worktree-root/primary"),
|
||||
(&primary_subdir, "/tmp/worktree-root/primary/shared/child"),
|
||||
(&nested_child, "/tmp/worktree-root/primary/subdir/child"),
|
||||
]
|
||||
.map(|(path, stable)| {
|
||||
(
|
||||
crate::text_formatting::center_truncate_path(
|
||||
&crate::status::format_directory_display(path, /*max_width*/ None),
|
||||
/*max_width*/ 64,
|
||||
),
|
||||
stable,
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
project_popup.contains(&format!("{} 4", project_headings[0].0)),
|
||||
"{project_popup}"
|
||||
);
|
||||
assert!(
|
||||
project_popup.contains(&format!("{} 2", project_headings[1].0)),
|
||||
"{project_popup}"
|
||||
);
|
||||
let mut project_grouping = project_popup
|
||||
.lines()
|
||||
.map(|line| line.split('│').next().unwrap_or(line).trim_end())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.replace(&headings[1], "/tmp/worktree-root/primary/subdir/child")
|
||||
.replace(display_heading, "/tmp/worktree-root/primary")
|
||||
.replace('\\', "/");
|
||||
.join("\n");
|
||||
for (heading, stable) in project_headings.iter().rev() {
|
||||
project_grouping = project_grouping.replace(heading, stable);
|
||||
}
|
||||
let project_grouping = project_grouping.replace('\\', "/");
|
||||
app.agents_overview.view_state.lock().unwrap().grouping = AgentsOverviewGrouping::Directory;
|
||||
let directory_popup = render_bottom_popup(&app.chat_widget, width);
|
||||
for (heading, _) in &directory_headings[..2] {
|
||||
assert!(
|
||||
directory_popup.contains(&format!("{heading} 2")),
|
||||
"{directory_popup}"
|
||||
);
|
||||
}
|
||||
let mut directory_grouping = directory_popup
|
||||
.lines()
|
||||
.map(|line| line.split('│').next().unwrap_or(line).trim_end())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
for (heading, stable) in directory_headings.iter().rev() {
|
||||
directory_grouping = directory_grouping.replace(heading, stable);
|
||||
}
|
||||
let directory_grouping = directory_grouping.replace('\\', "/");
|
||||
insta::with_settings!({snapshot_path => "../snapshots"}, {
|
||||
insta::assert_snapshot!(
|
||||
"agents_overview_worktree_grouping",
|
||||
normalize_agent_center_snapshot(grouping)
|
||||
normalize_agent_center_snapshot(project_grouping)
|
||||
);
|
||||
insta::assert_snapshot!(
|
||||
"agents_overview_directory_grouping",
|
||||
normalize_agent_center_snapshot(directory_grouping)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn overview_directory_group_keys_normalize_aliases() {
|
||||
use super::super::agents_overview_view::directory_group_key;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let checkout = root.path().join("checkout");
|
||||
let alias = root.path().join("alias");
|
||||
std::fs::create_dir(&checkout).unwrap();
|
||||
std::os::unix::fs::symlink(&checkout, &alias).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
directory_group_key(&checkout, /*worktrees_enabled*/ true),
|
||||
directory_group_key(&alias, /*worktrees_enabled*/ true),
|
||||
);
|
||||
let worktrees_enabled = false;
|
||||
assert_eq!(directory_group_key(&alias, worktrees_enabled), alias);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_model_grouping_shows_details_and_preserves_selection() {
|
||||
let mut app = make_test_app().await;
|
||||
@@ -1221,7 +1307,7 @@ async fn overview_model_grouping_shows_details_and_preserves_selection() {
|
||||
.collect();
|
||||
let selected = ThreadId::from_u128(/*value*/ 1);
|
||||
let mut view = app.agents_overview_view(threads, Some(selected));
|
||||
for _ in 0..2 {
|
||||
for _ in 0..3 {
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
|
||||
}
|
||||
assert_eq!(
|
||||
@@ -2971,18 +3057,40 @@ async fn overview_grouping_persists_across_config_reloads() -> Result<()> {
|
||||
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,
|
||||
for (selected, persisted) in [
|
||||
(
|
||||
AgentsOverviewGrouping::Directory,
|
||||
PersistedAgentsOverviewGrouping::Project,
|
||||
),
|
||||
(
|
||||
AgentsOverviewGrouping::Status,
|
||||
PersistedAgentsOverviewGrouping::Status,
|
||||
),
|
||||
(
|
||||
AgentsOverviewGrouping::Model,
|
||||
PersistedAgentsOverviewGrouping::Model,
|
||||
),
|
||||
(
|
||||
AgentsOverviewGrouping::Project,
|
||||
PersistedAgentsOverviewGrouping::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);
|
||||
assert_eq!(
|
||||
app.agents_overview.view_state.lock().unwrap().grouping,
|
||||
selected
|
||||
);
|
||||
if selected == AgentsOverviewGrouping::Directory {
|
||||
assert!(events.try_recv().is_err());
|
||||
assert!(!std::fs::read_to_string(&config_path)?.contains("agents_overview_grouping"));
|
||||
} else {
|
||||
let event = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.find(|event| matches!(event, AppEvent::PersistAgentsOverviewGrouping(_)))
|
||||
.expect("persisted grouping change emits persistence event");
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
}
|
||||
|
||||
let mut loader = codex_config::LoaderOverrides::without_managed_config_for_tests();
|
||||
loader.user_config_path = Some(AbsolutePathBuf::try_from(config_path.clone())?);
|
||||
@@ -2992,7 +3100,7 @@ async fn overview_grouping_persists_across_config_reloads() -> Result<()> {
|
||||
.build()
|
||||
.await?;
|
||||
app.local_settings = crate::local_settings::LocalSettings::from(&config);
|
||||
assert_eq!(app.local_settings.tui.agents_overview_grouping, expected);
|
||||
assert_eq!(app.local_settings.tui.agents_overview_grouping, persisted);
|
||||
assert!(!app.local_settings.tui.animations);
|
||||
}
|
||||
assert!(!home.path().join("config.toml").exists());
|
||||
@@ -3003,7 +3111,7 @@ async fn overview_grouping_persists_across_config_reloads() -> Result<()> {
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::PersistAgentsOverviewGrouping(AgentsOverviewGrouping::Status),
|
||||
AppEvent::PersistAgentsOverviewGrouping(PersistedAgentsOverviewGrouping::Status),
|
||||
))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
@@ -3012,7 +3120,7 @@ async fn overview_grouping_persists_across_config_reloads() -> Result<()> {
|
||||
);
|
||||
assert_eq!(
|
||||
app.local_settings.tui.agents_overview_grouping,
|
||||
AgentsOverviewGrouping::Status
|
||||
PersistedAgentsOverviewGrouping::Status
|
||||
);
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 100)
|
||||
@@ -3047,7 +3155,7 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone(
|
||||
));
|
||||
view.handle_key_event(KeyCode::Char('p').into());
|
||||
assert!(rx.try_recv().is_err());
|
||||
for _ in 0..3 {
|
||||
for grouping_index in 0..3 {
|
||||
view.handle_key_event(KeyCode::Char('n').into());
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewSession { cwd: Some(cwd) }) if cwd == target.cwd)
|
||||
@@ -3057,10 +3165,14 @@ 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(_))
|
||||
));
|
||||
if grouping_index == 0 {
|
||||
assert!(rx.try_recv().is_err());
|
||||
} else {
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::PersistAgentsOverviewGrouping(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
view.handle_key_event(KeyCode::Char('r').into());
|
||||
for character in "nwogrxfhap".chars() {
|
||||
|
||||
@@ -8,7 +8,7 @@ pub(super) mod command_center;
|
||||
#[path = "agents_overview_grouping.rs"]
|
||||
mod grouping;
|
||||
|
||||
pub(super) use codex_config::types::AgentsOverviewGrouping;
|
||||
pub(super) use grouping::AgentsOverviewGrouping;
|
||||
use grouping::model_name;
|
||||
|
||||
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
|
||||
@@ -57,6 +57,7 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
@@ -140,6 +141,13 @@ impl AgentsOverviewProjectGroup {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn directory_group_key(cwd: &Path, worktrees_enabled: bool) -> PathBuf {
|
||||
worktrees_enabled
|
||||
.then(|| dunce::canonicalize(cwd).ok())
|
||||
.flatten()
|
||||
.unwrap_or_else(|| cwd.to_path_buf())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AgentsOverviewViewState {
|
||||
row_order: HashMap<ThreadId, (AgentsOverviewGroup, usize)>,
|
||||
@@ -187,6 +195,7 @@ pub(super) struct AgentsOverviewView {
|
||||
use_theme_colors: bool,
|
||||
pub(super) rows: Vec<AgentsOverviewRow>,
|
||||
project_groups: Vec<AgentsOverviewProjectGroup>,
|
||||
directory_group_keys: Vec<PathBuf>,
|
||||
pub(super) pinned_thread_ranks: Option<HashMap<ThreadId, usize>>,
|
||||
pub(super) pin_action_pending: bool,
|
||||
selected: usize,
|
||||
@@ -241,6 +250,10 @@ impl AgentsOverviewView {
|
||||
.iter()
|
||||
.map(|row| AgentsOverviewProjectGroup::for_thread(&row.thread, worktrees_enabled))
|
||||
.collect();
|
||||
let directory_group_keys = rows
|
||||
.iter()
|
||||
.map(|row| directory_group_key(row.thread.cwd.as_path(), worktrees_enabled))
|
||||
.collect();
|
||||
let center_shortcut_keys = crate::keymap::keymap_action_ids()
|
||||
.filter(|action| matches!(action.context, KeymapContext::List | KeymapContext::Agents))
|
||||
.flat_map(|action| {
|
||||
@@ -277,6 +290,7 @@ impl AgentsOverviewView {
|
||||
use_theme_colors,
|
||||
rows,
|
||||
project_groups,
|
||||
directory_group_keys,
|
||||
pinned_thread_ranks: None,
|
||||
pin_action_pending: false,
|
||||
selected,
|
||||
@@ -681,12 +695,15 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
if self.agents_keymap.toggle_grouping.is_pressed(key) {
|
||||
let mut state = self.state();
|
||||
state.grouping = match state.grouping {
|
||||
AgentsOverviewGrouping::Project => AgentsOverviewGrouping::Status,
|
||||
AgentsOverviewGrouping::Project => AgentsOverviewGrouping::Directory,
|
||||
AgentsOverviewGrouping::Directory => AgentsOverviewGrouping::Status,
|
||||
AgentsOverviewGrouping::Status => AgentsOverviewGrouping::Model,
|
||||
AgentsOverviewGrouping::Model => AgentsOverviewGrouping::Project,
|
||||
};
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PersistAgentsOverviewGrouping(state.grouping));
|
||||
if let Some(grouping) = state.grouping.persisted() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PersistAgentsOverviewGrouping(grouping));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.agents_keymap.toggle_pin.is_pressed(key) {
|
||||
|
||||
+7
@@ -9,6 +9,13 @@ Project, filtered=false: › ○ Task 1
|
||||
Project, filtered=true: › ○ Task 3
|
||||
Project, filtered=true: › ○ Task 4
|
||||
Project, filtered=true: › ○ Task 1
|
||||
Directory, filtered=false: › ○ Task 3
|
||||
Directory, filtered=false: › ○ Task 4
|
||||
Directory, filtered=false: › ○ Other 2
|
||||
Directory, filtered=false: › ○ Task 1
|
||||
Directory, filtered=true: › ○ Task 3
|
||||
Directory, filtered=true: › ○ Task 4
|
||||
Directory, filtered=true: › ○ Task 1
|
||||
Status, filtered=false: › ○ Task 3
|
||||
Status, filtered=false: › ○ Task 1
|
||||
Status, filtered=false: › ○ Task 4
|
||||
|
||||
@@ -278,7 +278,7 @@ define_keymap_actions! {
|
||||
archive => "Archive the selected task and its child agents.",
|
||||
delete => "Permanently delete the selected task and its child agents.",
|
||||
hide => "Hide the selected task until explicitly resumed.",
|
||||
toggle_grouping => "Cycle task grouping by project, status, or model.",
|
||||
toggle_grouping => "Cycle task grouping by project, directory, status, or model.",
|
||||
}
|
||||
approval "Approval" {
|
||||
open_fullscreen => "Open approval details fullscreen.",
|
||||
|
||||
+1
-11
@@ -43,7 +43,6 @@ use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::Thread as AppServerThread;
|
||||
#[cfg(test)]
|
||||
use codex_app_server_protocol::ThreadListCwdFilter;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
|
||||
@@ -863,7 +862,6 @@ async fn lookup_session_target_with_app_server(
|
||||
}
|
||||
|
||||
async fn lookup_latest_session_target_with_app_server(
|
||||
uses_remote_filesystem: bool,
|
||||
app_server: &mut AppServerSession,
|
||||
config: &Config,
|
||||
cwd_filter: Option<&Path>,
|
||||
@@ -877,7 +875,6 @@ async fn lookup_latest_session_target_with_app_server(
|
||||
] {
|
||||
let response = app_server
|
||||
.thread_list(latest_session_lookup_params(
|
||||
uses_remote_filesystem,
|
||||
model_provider.clone(),
|
||||
cwd_filter,
|
||||
include_non_interactive,
|
||||
@@ -911,7 +908,6 @@ enum LatestSessionLookupMode {
|
||||
}
|
||||
|
||||
fn latest_session_lookup_params(
|
||||
uses_remote_filesystem: bool,
|
||||
model_provider: Option<String>,
|
||||
cwd_filter: Option<&Path>,
|
||||
include_non_interactive: bool,
|
||||
@@ -931,8 +927,7 @@ fn latest_session_lookup_params(
|
||||
project_id: None,
|
||||
parent_thread_id: None,
|
||||
ancestor_thread_id: None,
|
||||
cwd: cwd_filter
|
||||
.map(|cwd| resume_picker::repository_cwd_filter(cwd, uses_remote_filesystem)),
|
||||
cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())),
|
||||
use_state_db_only: match lookup_mode {
|
||||
LatestSessionLookupMode::StateDbOnly => true,
|
||||
LatestSessionLookupMode::ScanAndRepair => false,
|
||||
@@ -1550,10 +1545,6 @@ async fn run_ratatui_app(
|
||||
.run_until(
|
||||
&mut tui,
|
||||
lookup_latest_session_target_with_app_server(
|
||||
uses_remote_workspace_or_environment(
|
||||
&app_server_target,
|
||||
&environment_manager,
|
||||
),
|
||||
startup_app_server,
|
||||
&config,
|
||||
filter_cwd,
|
||||
@@ -1652,7 +1643,6 @@ async fn run_ratatui_app(
|
||||
.run_until(
|
||||
&mut tui,
|
||||
lookup_latest_session_target_with_app_server(
|
||||
uses_remote_workspace_or_environment(&app_server_target, &environment_manager),
|
||||
startup_app_server,
|
||||
&config,
|
||||
filter_cwd,
|
||||
|
||||
@@ -942,7 +942,6 @@ async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions()
|
||||
let cwd = temp_dir.path().join("project");
|
||||
|
||||
let params = latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ false,
|
||||
/*model_provider*/ None,
|
||||
Some(cwd.as_path()),
|
||||
/*include_non_interactive*/ false,
|
||||
@@ -957,7 +956,6 @@ async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions()
|
||||
assert!(params.use_state_db_only);
|
||||
|
||||
let scan_params = latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ false,
|
||||
/*model_provider*/ None,
|
||||
Some(cwd.as_path()),
|
||||
/*include_non_interactive*/ false,
|
||||
@@ -972,7 +970,6 @@ async fn latest_session_lookup_params_honor_explicit_provider() -> color_eyre::R
|
||||
let temp_dir = TempDir::new()?;
|
||||
let cwd = temp_dir.path().join("project");
|
||||
let params = latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ false,
|
||||
Some("selected-provider".to_string()),
|
||||
Some(cwd.as_path()),
|
||||
/*include_non_interactive*/ false,
|
||||
@@ -994,7 +991,6 @@ async fn latest_session_lookup_params_honor_explicit_provider() -> color_eyre::R
|
||||
async fn latest_session_lookup_params_omit_local_filters_for_remote_sessions() -> std::io::Result<()>
|
||||
{
|
||||
let params = latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ true,
|
||||
/*model_provider*/ None,
|
||||
/*cwd_filter*/ None,
|
||||
/*include_non_interactive*/ false,
|
||||
@@ -1009,7 +1005,6 @@ async fn latest_session_lookup_params_omit_local_filters_for_remote_sessions() -
|
||||
#[tokio::test]
|
||||
async fn latest_session_lookup_params_can_include_non_interactive_sources() -> std::io::Result<()> {
|
||||
let params = latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ true,
|
||||
/*model_provider*/ None,
|
||||
/*cwd_filter*/ None,
|
||||
/*include_non_interactive*/ true,
|
||||
@@ -1034,7 +1029,6 @@ async fn latest_session_lookup_params_keep_explicit_cwd_filter_for_remote_sessio
|
||||
let cwd = Path::new("repo/on/server");
|
||||
|
||||
let params = latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ true,
|
||||
/*model_provider*/ None,
|
||||
Some(cwd),
|
||||
/*include_non_interactive*/ false,
|
||||
@@ -1164,7 +1158,6 @@ async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre
|
||||
/*show_all*/ false,
|
||||
);
|
||||
let scoped_target = lookup_latest_session_target_with_app_server(
|
||||
/*uses_remote_filesystem*/ false,
|
||||
&mut app_server,
|
||||
&config,
|
||||
filter_cwd,
|
||||
@@ -1177,7 +1170,6 @@ async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre
|
||||
/*show_all*/ true,
|
||||
);
|
||||
let show_all_target = lookup_latest_session_target_with_app_server(
|
||||
/*uses_remote_filesystem*/ false,
|
||||
&mut app_server,
|
||||
&config,
|
||||
show_all_filter_cwd,
|
||||
@@ -1187,7 +1179,7 @@ async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre
|
||||
.expect("expected global fork --last target");
|
||||
app_server.shutdown().await?;
|
||||
|
||||
assert_eq!(scoped_target.thread_id, linked_thread_id);
|
||||
assert_eq!(scoped_target.thread_id, project_thread_id);
|
||||
assert_eq!(show_all_target.thread_id, other_thread_id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1225,7 +1217,6 @@ async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db()
|
||||
)?;
|
||||
|
||||
let target = lookup_latest_session_target_with_app_server(
|
||||
/*uses_remote_filesystem*/ false,
|
||||
&mut app_server,
|
||||
&config,
|
||||
Some(project_cwd.as_path()),
|
||||
|
||||
@@ -186,6 +186,7 @@ struct PageLoadRequest {
|
||||
request_token: usize,
|
||||
search_token: Option<usize>,
|
||||
mode: PageLoadMode,
|
||||
filter_mode: SessionFilterMode,
|
||||
cwd_filter: Option<PathBuf>,
|
||||
status: SessionStatus,
|
||||
provider_filter: ProviderFilter,
|
||||
@@ -218,6 +219,7 @@ enum ProviderFilter {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SessionFilterMode {
|
||||
Cwd,
|
||||
Repo,
|
||||
All,
|
||||
}
|
||||
|
||||
@@ -230,11 +232,20 @@ impl SessionFilterMode {
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(self, filter_cwd: Option<&Path>) -> Self {
|
||||
match self {
|
||||
Self::Cwd => Self::All,
|
||||
Self::All if filter_cwd.is_some() => Self::Cwd,
|
||||
Self::All => Self::All,
|
||||
fn change(
|
||||
self,
|
||||
direction: ToolbarValueDirection,
|
||||
filter_cwd: Option<&Path>,
|
||||
repo_filter_available: bool,
|
||||
) -> Self {
|
||||
match (self, direction) {
|
||||
(Self::Cwd, ToolbarValueDirection::Next) if repo_filter_available => Self::Repo,
|
||||
(Self::Cwd, ToolbarValueDirection::Next | ToolbarValueDirection::Previous)
|
||||
| (Self::Repo, ToolbarValueDirection::Next) => Self::All,
|
||||
(Self::Repo, ToolbarValueDirection::Previous) => Self::Cwd,
|
||||
(Self::All, ToolbarValueDirection::Previous) if repo_filter_available => Self::Repo,
|
||||
(Self::All, _) if filter_cwd.is_some() => Self::Cwd,
|
||||
(Self::All, _) => Self::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +263,11 @@ enum ToolbarControl {
|
||||
Sort,
|
||||
}
|
||||
|
||||
enum ToolbarValueDirection {
|
||||
Previous,
|
||||
Next,
|
||||
}
|
||||
|
||||
impl ToolbarControl {
|
||||
fn previous(self, action: SessionPickerAction) -> Self {
|
||||
match self {
|
||||
@@ -570,6 +586,12 @@ async fn run_session_picker_with_loader(
|
||||
picker_loader: PickerLoader,
|
||||
bg_rx: mpsc::UnboundedReceiver<BackgroundEvent>,
|
||||
) -> Result<SessionSelection> {
|
||||
let (repo_filter_tx, repo_filter_rx) = mpsc::unbounded_channel();
|
||||
if let Some(cwd) = options.local_filter_cwd.clone() {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _ = repo_filter_tx.send(local_repo_filter_available(&cwd));
|
||||
});
|
||||
}
|
||||
let alt = AltScreenGuard::enter(tui);
|
||||
let mut state = PickerState::new(
|
||||
options.backend_id,
|
||||
@@ -577,9 +599,12 @@ async fn run_session_picker_with_loader(
|
||||
picker_loader,
|
||||
options.provider_filter,
|
||||
options.show_all,
|
||||
options.filter_cwd,
|
||||
options.local_filter_cwd.clone(),
|
||||
options.action,
|
||||
);
|
||||
state.filter_mode =
|
||||
SessionFilterMode::from_show_all(options.show_all, options.filter_cwd.as_deref());
|
||||
state.filter_cwd = options.filter_cwd;
|
||||
state.local_filter_cwd = options.local_filter_cwd;
|
||||
state.use_theme_colors = options.use_theme_colors;
|
||||
state.copy_on_select = options.copy_on_select;
|
||||
@@ -604,6 +629,7 @@ async fn run_session_picker_with_loader(
|
||||
}
|
||||
let mut tui_events = alt.tui.event_stream().fuse();
|
||||
let mut background_events = UnboundedReceiverStream::new(bg_rx).fuse();
|
||||
let mut repo_filter_events = UnboundedReceiverStream::new(repo_filter_rx).fuse();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -658,6 +684,9 @@ async fn run_session_picker_with_loader(
|
||||
return Ok(selection);
|
||||
}
|
||||
}
|
||||
Some(available) = repo_filter_events.next() => {
|
||||
state.set_repo_filter_available(available);
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
@@ -735,6 +764,7 @@ fn spawn_app_server_page_loader(
|
||||
let cwd_filter = page_cwd_filter.for_request(
|
||||
request.cursor.as_ref(),
|
||||
request.cwd_filter.as_deref(),
|
||||
request.filter_mode,
|
||||
uses_remote_filesystem,
|
||||
);
|
||||
let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor);
|
||||
@@ -888,6 +918,7 @@ struct PickerState {
|
||||
status: SessionStatus,
|
||||
filter_cwd: Option<PathBuf>,
|
||||
local_filter_cwd: Option<PathBuf>,
|
||||
repo_filter_available: bool,
|
||||
toolbar_focus: ToolbarControl,
|
||||
density: SessionListDensity,
|
||||
launch_context: SessionPickerLaunchContext,
|
||||
@@ -1091,6 +1122,7 @@ impl PickerState {
|
||||
local_filter_cwd: filter_cwd.clone(),
|
||||
local_cwd_matches: HashMap::new(),
|
||||
filter_cwd,
|
||||
repo_filter_available: false,
|
||||
toolbar_focus: ToolbarControl::Filter,
|
||||
density: SessionListDensity::Comfortable,
|
||||
launch_context: SessionPickerLaunchContext::Startup,
|
||||
@@ -1143,6 +1175,11 @@ impl PickerState {
|
||||
self.requester.schedule_frame();
|
||||
}
|
||||
|
||||
fn set_repo_filter_available(&mut self, available: bool) {
|
||||
self.repo_filter_available = available;
|
||||
self.request_frame();
|
||||
}
|
||||
|
||||
fn is_transcript_loading(&self) -> bool {
|
||||
self.pending_transcript_open.is_some()
|
||||
}
|
||||
@@ -1421,11 +1458,12 @@ impl PickerState {
|
||||
self.focus_previous_toolbar_control();
|
||||
self.request_frame();
|
||||
}
|
||||
_ if allow_plain_char_navigation
|
||||
&& (self.keymap.list.move_left.is_pressed(key)
|
||||
|| self.keymap.list.move_right.is_pressed(key)) =>
|
||||
{
|
||||
self.change_focused_toolbar_value();
|
||||
_ if allow_plain_char_navigation && self.keymap.list.move_left.is_pressed(key) => {
|
||||
self.change_focused_toolbar_value(ToolbarValueDirection::Previous);
|
||||
self.request_frame();
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.keymap.list.move_right.is_pressed(key) => {
|
||||
self.change_focused_toolbar_value(ToolbarValueDirection::Next);
|
||||
self.request_frame();
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -1506,6 +1544,7 @@ impl PickerState {
|
||||
request_token,
|
||||
search_token,
|
||||
mode,
|
||||
filter_mode: self.filter_mode,
|
||||
cwd_filter: self.active_cwd_filter(),
|
||||
status: self.status,
|
||||
provider_filter: self.provider_filter.clone(),
|
||||
@@ -1546,6 +1585,7 @@ impl PickerState {
|
||||
request_token,
|
||||
search_token,
|
||||
mode: PageLoadMode::StoreDefault,
|
||||
filter_mode: self.filter_mode,
|
||||
cwd_filter: self.active_cwd_filter(),
|
||||
status: self.status,
|
||||
provider_filter: self.provider_filter.clone(),
|
||||
@@ -1716,11 +1756,13 @@ impl PickerState {
|
||||
return false;
|
||||
};
|
||||
paths_match(row_cwd, filter_cwd)
|
||||
|| codex_git_utils::repository_identity(row_cwd)
|
||||
.zip(codex_git_utils::repository_identity(filter_cwd))
|
||||
.is_some_and(|(row, filter)| {
|
||||
row.common_dir == filter.common_dir && row.relative_cwd == filter.relative_cwd
|
||||
})
|
||||
|| (self.filter_mode == SessionFilterMode::Repo
|
||||
&& codex_git_utils::repository_identity(row_cwd)
|
||||
.zip(codex_git_utils::repository_identity(filter_cwd))
|
||||
.is_some_and(|(row, filter)| {
|
||||
row.common_dir == filter.common_dir
|
||||
&& row.relative_cwd == filter.relative_cwd
|
||||
}))
|
||||
}
|
||||
|
||||
fn set_query(&mut self, new_query: String) {
|
||||
@@ -1871,6 +1913,7 @@ impl PickerState {
|
||||
request_token,
|
||||
search_token,
|
||||
mode,
|
||||
filter_mode: self.filter_mode,
|
||||
cwd_filter: self.active_cwd_filter(),
|
||||
status: self.status,
|
||||
provider_filter: self.provider_filter.clone(),
|
||||
@@ -1910,8 +1953,12 @@ impl PickerState {
|
||||
self.start_initial_load();
|
||||
}
|
||||
|
||||
fn toggle_filter_mode(&mut self) {
|
||||
let next_filter_mode = self.filter_mode.toggle(self.filter_cwd.as_deref());
|
||||
fn change_filter_mode(&mut self, direction: ToolbarValueDirection) {
|
||||
let next_filter_mode = self.filter_mode.change(
|
||||
direction,
|
||||
self.filter_cwd.as_deref(),
|
||||
self.repo_filter_available,
|
||||
);
|
||||
if self.filter_mode == next_filter_mode {
|
||||
return;
|
||||
}
|
||||
@@ -1929,7 +1976,7 @@ impl PickerState {
|
||||
|
||||
fn active_cwd_filter(&self) -> Option<PathBuf> {
|
||||
match self.filter_mode {
|
||||
SessionFilterMode::Cwd => self.filter_cwd.clone(),
|
||||
SessionFilterMode::Cwd | SessionFilterMode::Repo => self.filter_cwd.clone(),
|
||||
SessionFilterMode::All => None,
|
||||
}
|
||||
}
|
||||
@@ -1942,10 +1989,10 @@ impl PickerState {
|
||||
self.toolbar_focus = self.toolbar_focus.next(self.action);
|
||||
}
|
||||
|
||||
fn change_focused_toolbar_value(&mut self) {
|
||||
fn change_focused_toolbar_value(&mut self, direction: ToolbarValueDirection) {
|
||||
match self.toolbar_focus {
|
||||
ToolbarControl::Sort => self.toggle_sort_key(),
|
||||
ToolbarControl::Filter => self.toggle_filter_mode(),
|
||||
ToolbarControl::Filter => self.change_filter_mode(direction),
|
||||
ToolbarControl::Status => self.toggle_status(),
|
||||
}
|
||||
}
|
||||
@@ -2152,6 +2199,16 @@ pub(crate) fn repository_cwd_filter(
|
||||
ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn local_repo_filter_available(cwd: &Path) -> bool {
|
||||
codex_git_utils::linked_worktree_cwds(cwd).is_some_and(|cwds| {
|
||||
cwds.into_iter()
|
||||
.filter_map(|cwd| dunce::canonicalize(cwd).ok())
|
||||
.collect::<HashSet<_>>()
|
||||
.len()
|
||||
> 1
|
||||
})
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
path_utils::paths_match_after_normalization(a, b)
|
||||
}
|
||||
@@ -2289,19 +2346,27 @@ fn filter_control_spans(state: &PickerState, compact: bool) -> Vec<Span<'static>
|
||||
),
|
||||
];
|
||||
}
|
||||
vec![
|
||||
let mut values = vec![
|
||||
"Filter: ".set_style(secondary_text_style()),
|
||||
toolbar_value(
|
||||
filter_mode_label(SessionFilterMode::Cwd),
|
||||
state.filter_mode == SessionFilterMode::Cwd,
|
||||
filter_focused,
|
||||
),
|
||||
toolbar_value(
|
||||
filter_mode_label(SessionFilterMode::All),
|
||||
state.filter_mode == SessionFilterMode::All,
|
||||
];
|
||||
if state.repo_filter_available {
|
||||
values.push(toolbar_value(
|
||||
filter_mode_label(SessionFilterMode::Repo),
|
||||
state.filter_mode == SessionFilterMode::Repo,
|
||||
filter_focused,
|
||||
),
|
||||
]
|
||||
));
|
||||
}
|
||||
values.push(toolbar_value(
|
||||
filter_mode_label(SessionFilterMode::All),
|
||||
state.filter_mode == SessionFilterMode::All,
|
||||
filter_focused,
|
||||
));
|
||||
values
|
||||
}
|
||||
|
||||
fn toolbar_value(label: &'static str, active: bool, focused: bool) -> Span<'static> {
|
||||
@@ -2321,6 +2386,7 @@ fn toolbar_value(label: &'static str, active: bool, focused: bool) -> Span<'stat
|
||||
fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str {
|
||||
match filter_mode {
|
||||
SessionFilterMode::Cwd => "Cwd",
|
||||
SessionFilterMode::Repo => "Repo",
|
||||
SessionFilterMode::All => "All",
|
||||
}
|
||||
}
|
||||
@@ -3773,7 +3839,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linked_worktree_sessions_are_included_for_local_repositories() {
|
||||
async fn linked_worktree_sessions_follow_directory_scope() {
|
||||
let root = tempfile::tempdir().expect("temporary repository");
|
||||
let primary = root.path().join("primary");
|
||||
let linked = root.path().join("linked");
|
||||
@@ -3802,6 +3868,7 @@ mod tests {
|
||||
let mut row = make_row("session.jsonl", "2025-01-01T00:00:00Z", "Linked session");
|
||||
row.cwd = Some(linked.clone());
|
||||
let single = ThreadListCwdFilter::One(primary.display().to_string());
|
||||
state.repo_filter_available = local_repo_filter_available(&primary);
|
||||
state.start_initial_load();
|
||||
state.ingest_page(page(
|
||||
vec![row.clone()],
|
||||
@@ -3809,6 +3876,14 @@ mod tests {
|
||||
/*num_scanned_files*/ 1,
|
||||
/*reached_scan_cap*/ false,
|
||||
));
|
||||
assert!(state.filtered_rows.is_empty());
|
||||
state.change_filter_mode(ToolbarValueDirection::Next);
|
||||
state.ingest_page(page(
|
||||
vec![row.clone()],
|
||||
/*next_cursor*/ None,
|
||||
/*num_scanned_files*/ 1,
|
||||
/*reached_scan_cap*/ false,
|
||||
));
|
||||
assert_eq!(state.filtered_rows, vec![row.clone()]);
|
||||
state
|
||||
.pagination
|
||||
@@ -3887,6 +3962,22 @@ mod tests {
|
||||
.handle_background_event(event)
|
||||
.await
|
||||
.expect("ingest app-server page");
|
||||
assert!(
|
||||
live_picker
|
||||
.filtered_rows
|
||||
.iter()
|
||||
.all(|row| row.cwd.as_deref() == Some(primary.as_path()))
|
||||
);
|
||||
live_picker.filter_mode = SessionFilterMode::Repo;
|
||||
live_picker.start_initial_load();
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(10), bg_rx.recv())
|
||||
.await
|
||||
.expect("app-server repo page response")
|
||||
.expect("repo page event");
|
||||
live_picker
|
||||
.handle_background_event(event)
|
||||
.await
|
||||
.expect("ingest app-server repo page");
|
||||
let linked_index = live_picker
|
||||
.filtered_rows
|
||||
.iter()
|
||||
@@ -3906,18 +3997,15 @@ mod tests {
|
||||
live_picker.handle_key(accept).await.expect("fork selection"),
|
||||
Some(SessionSelection::Fork(SessionTarget { thread_id, .. })) if thread_id == linked_id
|
||||
));
|
||||
let mut local = crate::latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ false,
|
||||
let local = crate::latest_session_lookup_params(
|
||||
/*model_provider*/ None,
|
||||
Some(&primary),
|
||||
/*include_non_interactive*/ false,
|
||||
crate::LatestSessionLookupMode::StateDbOnly,
|
||||
);
|
||||
assert!(matches!(local.cwd, Some(ThreadListCwdFilter::Many(_))));
|
||||
local.cwd = Some(single.clone());
|
||||
assert_eq!(local.cwd, Some(single.clone()));
|
||||
assert_eq!(
|
||||
crate::latest_session_lookup_params(
|
||||
/*uses_remote_filesystem*/ true,
|
||||
/*model_provider*/ None,
|
||||
Some(&primary),
|
||||
/*include_non_interactive*/ false,
|
||||
@@ -5606,7 +5694,7 @@ session_picker_view = "dense"
|
||||
use crate::test_backend::VT100Backend;
|
||||
|
||||
let loader = page_only_loader(|_| {});
|
||||
let state = PickerState::new(
|
||||
let mut state = PickerState::new(
|
||||
crate::backend_id::BackendId::new(),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
@@ -5615,6 +5703,7 @@ session_picker_view = "dense"
|
||||
Some(PathBuf::from("/tmp/project")),
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.set_repo_filter_available(/*available*/ true);
|
||||
|
||||
let width: u16 = 100;
|
||||
let backend = VT100Backend::new(width, /*height*/ 1);
|
||||
@@ -6362,10 +6451,28 @@ session_picker_view = "dense"
|
||||
.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state.filter_mode, SessionFilterMode::All);
|
||||
state.set_repo_filter_available(/*available*/ true);
|
||||
assert_eq!(state.filter_mode, SessionFilterMode::All);
|
||||
|
||||
let guard = recorded_requests.lock().unwrap();
|
||||
assert_eq!(guard.len(), 2);
|
||||
assert_eq!(guard[1].cwd_filter, None);
|
||||
let cwd_filter = Some(Path::new("/tmp/project"));
|
||||
for (key, filter_mode, expected_cwd) in [
|
||||
(KeyCode::Left, SessionFilterMode::Repo, cwd_filter),
|
||||
(KeyCode::Left, SessionFilterMode::Cwd, cwd_filter),
|
||||
(KeyCode::Right, SessionFilterMode::Repo, cwd_filter),
|
||||
(KeyCode::Right, SessionFilterMode::All, None),
|
||||
] {
|
||||
state
|
||||
.handle_key(KeyEvent::new(key, KeyModifiers::NONE))
|
||||
.await
|
||||
.unwrap();
|
||||
let guard = recorded_requests.lock().unwrap();
|
||||
let request = guard.last().expect("filter reload request");
|
||||
assert_eq!(
|
||||
(request.filter_mode, request.cwd_filter.as_deref()),
|
||||
(filter_mode, expected_cwd)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -218,7 +218,7 @@ fn archived_status_preserves_directory_filter_and_hides_archive_shortcut() {
|
||||
@"Filter: Cwd Archived Sort: Updated "
|
||||
);
|
||||
|
||||
state.toggle_filter_mode();
|
||||
state.change_filter_mode(super::super::ToolbarValueDirection::Next);
|
||||
|
||||
assert_eq!(
|
||||
*requested_filters.lock().unwrap(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::PageCursor;
|
||||
use super::SessionFilterMode;
|
||||
use super::repository_cwd_filter;
|
||||
use codex_app_server_protocol::ThreadListCwdFilter;
|
||||
|
||||
@@ -17,10 +18,19 @@ impl PageCwdFilter {
|
||||
&mut self,
|
||||
cursor: Option<&PageCursor>,
|
||||
cwd: Option<&Path>,
|
||||
filter_mode: SessionFilterMode,
|
||||
uses_remote_filesystem: bool,
|
||||
) -> Option<ThreadListCwdFilter> {
|
||||
if cursor.is_none() {
|
||||
self.current = cwd.map(|cwd| repository_cwd_filter(cwd, uses_remote_filesystem));
|
||||
self.current = match filter_mode {
|
||||
SessionFilterMode::Cwd => {
|
||||
cwd.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned()))
|
||||
}
|
||||
SessionFilterMode::Repo => {
|
||||
cwd.map(|cwd| repository_cwd_filter(cwd, uses_remote_filesystem))
|
||||
}
|
||||
SessionFilterMode::All => None,
|
||||
};
|
||||
}
|
||||
self.current.clone()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Checks that a cursor keeps its original checkout filter until the listing restarts.
|
||||
//! Checks that a cursor keeps its original directory scope until the listing restarts.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn linked_checkout_changes_only_affect_the_next_listing_cycle() {
|
||||
fn directory_scope_changes_only_affect_the_next_listing_cycle() {
|
||||
let root = tempfile::tempdir().expect("temporary repository");
|
||||
let primary = root.path().join("primary");
|
||||
let linked = root.path().join("linked");
|
||||
@@ -20,6 +20,7 @@ fn linked_checkout_changes_only_affect_the_next_listing_cycle() {
|
||||
filter.for_request(
|
||||
/*cursor*/ None,
|
||||
Some(&primary),
|
||||
SessionFilterMode::Cwd,
|
||||
/*uses_remote_filesystem*/ false,
|
||||
),
|
||||
single
|
||||
@@ -41,11 +42,21 @@ fn linked_checkout_changes_only_affect_the_next_listing_cycle() {
|
||||
filter.for_request(
|
||||
Some(&cursor),
|
||||
Some(&primary),
|
||||
SessionFilterMode::Repo,
|
||||
/*uses_remote_filesystem*/ false,
|
||||
),
|
||||
single
|
||||
);
|
||||
let both = Some(ThreadListCwdFilter::Many(vec![
|
||||
assert_eq!(
|
||||
filter.for_request(
|
||||
/*cursor*/ None,
|
||||
Some(&primary),
|
||||
SessionFilterMode::Cwd,
|
||||
/*uses_remote_filesystem*/ false,
|
||||
),
|
||||
single
|
||||
);
|
||||
let repo = Some(ThreadListCwdFilter::Many(vec![
|
||||
primary.to_string_lossy().into_owned(),
|
||||
linked.to_string_lossy().into_owned(),
|
||||
]));
|
||||
@@ -53,25 +64,37 @@ fn linked_checkout_changes_only_affect_the_next_listing_cycle() {
|
||||
filter.for_request(
|
||||
/*cursor*/ None,
|
||||
Some(&primary),
|
||||
SessionFilterMode::Repo,
|
||||
/*uses_remote_filesystem*/ false,
|
||||
),
|
||||
both
|
||||
repo
|
||||
);
|
||||
std::fs::remove_file(linked.join(".git")).expect("remove linked checkout registration");
|
||||
assert_eq!(
|
||||
filter.for_request(
|
||||
Some(&cursor),
|
||||
Some(&primary),
|
||||
SessionFilterMode::Cwd,
|
||||
/*uses_remote_filesystem*/ false,
|
||||
),
|
||||
both
|
||||
repo
|
||||
);
|
||||
assert_eq!(
|
||||
filter.for_request(
|
||||
/*cursor*/ None,
|
||||
Some(&primary),
|
||||
SessionFilterMode::Repo,
|
||||
/*uses_remote_filesystem*/ false,
|
||||
),
|
||||
single
|
||||
);
|
||||
assert_eq!(
|
||||
filter.for_request(
|
||||
/*cursor*/ None,
|
||||
Some(&primary),
|
||||
SessionFilterMode::All,
|
||||
/*uses_remote_filesystem*/ false,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_tests.rs
|
||||
expression: normalize_agent_center_snapshot(directory_grouping)
|
||||
---
|
||||
Agent command center Group: Directory g
|
||||
All 7 Needs you 0 Working 0 Ready 7 Inactive 0 tab/⇧tab filter
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Tasks Status Updated
|
||||
/tmp/worktree-root/linked 2
|
||||
○ Linked README Ready 2m ago
|
||||
○ Linked tree Ready 2m ago
|
||||
|
||||
/tmp/worktree-root/linked/shared/child 1
|
||||
○ Linked child Ready 2m ago
|
||||
|
||||
/tmp/worktree-root/primary 2
|
||||
› ○ Primary README Ready 2m ago
|
||||
○ Primary tree Ready 2m ago
|
||||
|
||||
/tmp/worktree-root/primary/shared/child 1
|
||||
○ Primary child Ready 2m ago
|
||||
|
||||
/tmp/worktree-root/primary/subdir/child 1
|
||||
○ Nested repository Ready 2m ago
|
||||
|
||||
|
||||
|
||||
? help esc back ↑/↓ move enter open n new
|
||||
+12
-12
@@ -1,23 +1,23 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_tests.rs
|
||||
expression: normalize_agent_center_snapshot(grouping)
|
||||
expression: normalize_agent_center_snapshot(project_grouping)
|
||||
---
|
||||
Agent command center Group: Project g
|
||||
All 4 Needs you 0 Working 0 Ready 4 Inactive 0 tab/⇧tab filter
|
||||
All 7 Needs you 0 Working 0 Ready 7 Inactive 0 tab/⇧tab filter
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Tasks Status Updated
|
||||
/tmp/worktree-root/primary 2
|
||||
› ○ Task Ready 2m ago
|
||||
○ Task Ready 2m ago
|
||||
/tmp/worktree-root/primary 4
|
||||
› ○ Primary README Ready 2m ago
|
||||
○ Primary tree Ready 2m ago
|
||||
○ Linked README Ready 2m ago
|
||||
○ Linked tree Ready 2m ago
|
||||
|
||||
/tmp/worktree-root/primary/shared/child 2
|
||||
○ Primary child Ready 2m ago
|
||||
○ Linked child Ready 2m ago
|
||||
|
||||
/tmp/worktree-root/primary/subdir/child 1
|
||||
○ Task Ready 2m ago
|
||||
|
||||
/tmp/worktree-root/primary/subdir/child 1
|
||||
○ Task Ready 2m ago
|
||||
|
||||
|
||||
|
||||
○ Nested repository Ready 2m ago
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ Stop | x | Agents stop Stop Stop the selected running task. x Default
|
||||
Archive | a | Agents archive Archive Archive the selected task and its child agents. a Default
|
||||
Delete | backspace | Agents delete Delete Permanently delete the selected task and its child agents. backspace Default
|
||||
Hide | h | Agents hide Hide Hide the selected task until explicitly resumed. h Default
|
||||
Toggle Grouping | g | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, status, or model. g Default
|
||||
Toggle Grouping | g | Agents toggle_grouping Toggle Grouping Cycle task grouping by project, directory, status, or model. g Default
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: terminal.backend().to_string()
|
||||
---
|
||||
Filter: Cwd All Status: Active Archived Sort: Updated Created
|
||||
Filter: Cwd Repo All Status: Active Archived Sort: Updated Created
|
||||
|
||||
Reference in New Issue
Block a user