feat: add ecc2 agent output filters

This commit is contained in:
Affaan Mustafa
2026-04-09 04:21:23 -07:00
parent 1755069df2
commit bab03bd8af
2 changed files with 198 additions and 17 deletions

View File

@@ -76,6 +76,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(),
(_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(), (_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(),
(_, KeyCode::Char('A')) => dashboard.toggle_search_scope(), (_, KeyCode::Char('A')) => dashboard.toggle_search_scope(),
(_, KeyCode::Char('o')) => dashboard.toggle_search_agent_filter(),
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
(_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(),

View File

@@ -85,6 +85,7 @@ pub struct Dashboard {
search_input: Option<String>, search_input: Option<String>,
search_query: Option<String>, search_query: Option<String>,
search_scope: SearchScope, search_scope: SearchScope,
search_agent_filter: SearchAgentFilter,
search_matches: Vec<SearchMatch>, search_matches: Vec<SearchMatch>,
selected_search_match: usize, selected_search_match: usize,
session_table_state: TableState, session_table_state: TableState,
@@ -140,6 +141,12 @@ enum SearchScope {
AllSessions, AllSessions,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchAgentFilter {
AllAgents,
SelectedAgentType,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct SearchMatch { struct SearchMatch {
session_id: String, session_id: String,
@@ -236,6 +243,7 @@ impl Dashboard {
search_input: None, search_input: None,
search_query: None, search_query: None,
search_scope: SearchScope::SelectedSession, search_scope: SearchScope::SelectedSession,
search_agent_filter: SearchAgentFilter::AllAgents,
search_matches: Vec::new(), search_matches: Vec::new(),
selected_search_match: 0, selected_search_match: 0,
session_table_state, session_table_state,
@@ -503,8 +511,9 @@ impl Dashboard {
self.output_time_filter.title_suffix() self.output_time_filter.title_suffix()
); );
let scope = self.search_scope.title_suffix(); let scope = self.search_scope.title_suffix();
let agent = self.search_agent_title_suffix();
if let Some(input) = self.search_input.as_ref() { if let Some(input) = self.search_input.as_ref() {
return format!(" Output{filter}{scope} /{input}_ "); return format!(" Output{filter}{scope}{agent} /{input}_ ");
} }
if let Some(query) = self.search_query.as_ref() { if let Some(query) = self.search_query.as_ref() {
@@ -514,10 +523,10 @@ impl Dashboard {
} else { } else {
self.selected_search_match.min(total.saturating_sub(1)) + 1 self.selected_search_match.min(total.saturating_sub(1)) + 1
}; };
return format!(" Output{filter}{scope} /{query} {current}/{total} "); return format!(" Output{filter}{scope}{agent} /{query} {current}/{total} ");
} }
format!(" Output{filter}{scope} ") format!(" Output{filter}{scope}{agent} ")
} }
fn empty_output_message(&self) -> &'static str { fn empty_output_message(&self) -> &'static str {
@@ -647,15 +656,16 @@ impl Dashboard {
fn render_status_bar(&self, frame: &mut Frame, area: Rect) { fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
let base_text = format!( let base_text = format!(
" [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol [e]rrors time [f]ilter search scope [A] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol [e]rrors time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
self.layout_label(), self.layout_label(),
self.theme_label() self.theme_label()
); );
let search_prefix = if let Some(input) = self.search_input.as_ref() { let search_prefix = if let Some(input) = self.search_input.as_ref() {
format!( format!(
" /{input}_ | {} | [Enter] apply [Esc] cancel |", " /{input}_ | {} | {} | [Enter] apply [Esc] cancel |",
self.search_scope.label() self.search_scope.label(),
self.search_agent_filter_label()
) )
} else if let Some(query) = self.search_query.as_ref() { } else if let Some(query) = self.search_query.as_ref() {
let total = self.search_matches.len(); let total = self.search_matches.len();
@@ -665,8 +675,9 @@ impl Dashboard {
self.selected_search_match.min(total.saturating_sub(1)) + 1 self.selected_search_match.min(total.saturating_sub(1)) + 1
}; };
format!( format!(
" /{query} {current}/{total} | {} | [n/N] navigate [Esc] clear |", " /{query} {current}/{total} | {} | {} | [n/N] navigate [Esc] clear |",
self.search_scope.label() self.search_scope.label(),
self.search_agent_filter_label()
) )
} else { } else {
String::new() String::new()
@@ -727,6 +738,7 @@ impl Dashboard {
" e Toggle output filter between all lines and stderr only", " e Toggle output filter between all lines and stderr only",
" f Cycle output time filter between all/15m/1h/24h", " f Cycle output time filter between all/15m/1h/24h",
" A Toggle search scope between selected session and all sessions", " A Toggle search scope between selected session and all sessions",
" o Toggle search agent filter between all agents and selected agent type",
" m Merge selected ready worktree into base and clean it up", " m Merge selected ready worktree into base and clean it up",
" M Merge all ready inactive worktrees and clean them up", " M Merge all ready inactive worktrees and clean them up",
" l Cycle pane layout and persist it", " l Cycle pane layout and persist it",
@@ -1678,6 +1690,40 @@ impl Dashboard {
} }
} }
pub fn toggle_search_agent_filter(&mut self) {
if self.output_mode != OutputMode::SessionOutput {
self.set_operator_note(
"search agent filter is only available in session output view".to_string(),
);
return;
}
let Some(selected_agent_type) = self.selected_agent_type().map(str::to_owned) else {
self.set_operator_note("search agent filter requires a selected session".to_string());
return;
};
self.search_agent_filter = match self.search_agent_filter {
SearchAgentFilter::AllAgents => SearchAgentFilter::SelectedAgentType,
SearchAgentFilter::SelectedAgentType => SearchAgentFilter::AllAgents,
};
self.recompute_search_matches();
self.sync_output_scroll(self.last_output_height.max(1));
if self.search_query.is_some() {
self.set_operator_note(format!(
"search agent filter set to {} | {} match(es)",
self.search_agent_filter.label(&selected_agent_type),
self.search_matches.len()
));
} else {
self.set_operator_note(format!(
"search agent filter set to {}",
self.search_agent_filter.label(&selected_agent_type)
));
}
}
pub fn begin_search(&mut self) { pub fn begin_search(&mut self) {
if self.output_mode != OutputMode::SessionOutput { if self.output_mode != OutputMode::SessionOutput {
self.set_operator_note("search is only available in session output view".to_string()); self.set_operator_note("search is only available in session output view".to_string());
@@ -2288,6 +2334,28 @@ impl Dashboard {
.unwrap_or(&[]) .unwrap_or(&[])
} }
fn selected_agent_type(&self) -> Option<&str> {
self.sessions
.get(self.selected_session)
.map(|session| session.agent_type.as_str())
}
fn search_agent_filter_label(&self) -> String {
self.search_agent_filter
.label(self.selected_agent_type().unwrap_or("selected agent"))
.to_string()
}
fn search_agent_title_suffix(&self) -> String {
match self.selected_agent_type() {
Some(agent_type) => self
.search_agent_filter
.title_suffix(agent_type)
.to_string(),
None => String::new(),
}
}
fn visible_output_lines_for_session(&self, session_id: &str) -> Vec<&OutputLine> { fn visible_output_lines_for_session(&self, session_id: &str) -> Vec<&OutputLine> {
self.session_output_cache self.session_output_cache
.get(session_id) .get(session_id)
@@ -2323,8 +2391,7 @@ impl Dashboard {
}; };
self.search_matches = self self.search_matches = self
.search_scope .search_target_session_ids()
.session_ids(self.selected_session_id(), &self.sessions)
.into_iter() .into_iter()
.flat_map(|session_id| { .flat_map(|session_id| {
self.visible_output_lines_for_session(session_id) self.visible_output_lines_for_session(session_id)
@@ -2397,6 +2464,23 @@ impl Dashboard {
.len() .len()
} }
fn search_target_session_ids(&self) -> Vec<&str> {
let selected_session_id = self.selected_session_id();
let selected_agent_type = self.selected_agent_type();
self.sessions
.iter()
.filter(|session| {
self.search_scope
.matches(selected_session_id, session.id.as_str())
&& self
.search_agent_filter
.matches(selected_agent_type, session.agent_type.as_str())
})
.map(|session| session.id.as_str())
.collect()
}
fn sync_output_scroll(&mut self, viewport_height: usize) { fn sync_output_scroll(&mut self, viewport_height: usize) {
self.last_output_height = viewport_height.max(1); self.last_output_height = viewport_height.max(1);
let max_scroll = self.max_output_scroll(); let max_scroll = self.max_output_scroll();
@@ -3089,14 +3173,33 @@ impl SearchScope {
} }
} }
fn session_ids<'a>( fn matches(self, selected_session_id: Option<&str>, session_id: &str) -> bool {
self,
selected_session_id: Option<&'a str>,
sessions: &'a [Session],
) -> Vec<&'a str> {
match self { match self {
Self::SelectedSession => selected_session_id.into_iter().collect(), Self::SelectedSession => selected_session_id == Some(session_id),
Self::AllSessions => sessions.iter().map(|session| session.id.as_str()).collect(), Self::AllSessions => true,
}
}
}
impl SearchAgentFilter {
fn matches(self, selected_agent_type: Option<&str>, session_agent_type: &str) -> bool {
match self {
Self::AllAgents => true,
Self::SelectedAgentType => selected_agent_type == Some(session_agent_type),
}
}
fn label(self, selected_agent_type: &str) -> String {
match self {
Self::AllAgents => "all agents".to_string(),
Self::SelectedAgentType => format!("agent {}", selected_agent_type),
}
}
fn title_suffix(self, selected_agent_type: &str) -> String {
match self {
Self::AllAgents => String::new(),
Self::SelectedAgentType => format!(" {}", self.label(selected_agent_type)),
} }
} }
} }
@@ -4770,6 +4873,82 @@ diff --git a/src/next.rs b/src/next.rs
); );
} }
#[test]
fn search_agent_filter_selected_agent_type_limits_global_search() {
let mut dashboard = test_dashboard(
vec![
sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
),
sample_session(
"planner-2222222",
"planner",
SessionState::Running,
None,
1,
1,
),
sample_session(
"review-87654321",
"reviewer",
SessionState::Running,
None,
1,
1,
),
],
0,
);
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![test_output_line(OutputStream::Stdout, "alpha local")],
);
dashboard.session_output_cache.insert(
"planner-2222222".to_string(),
vec![test_output_line(OutputStream::Stdout, "alpha planner")],
);
dashboard.session_output_cache.insert(
"review-87654321".to_string(),
vec![test_output_line(OutputStream::Stdout, "alpha reviewer")],
);
dashboard.search_scope = SearchScope::AllSessions;
dashboard.search_query = Some("alpha.*".to_string());
dashboard.recompute_search_matches();
dashboard.toggle_search_agent_filter();
assert_eq!(
dashboard.search_agent_filter,
SearchAgentFilter::SelectedAgentType
);
assert_eq!(
dashboard.search_matches,
vec![
SearchMatch {
session_id: "focus-12345678".to_string(),
line_index: 0,
},
SearchMatch {
session_id: "planner-2222222".to_string(),
line_index: 0,
},
]
);
assert_eq!(
dashboard.operator_note.as_deref(),
Some("search agent filter set to agent planner | 2 match(es)")
);
assert_eq!(
dashboard.output_title(),
" Output all sessions agent planner /alpha.* 1/2 "
);
}
#[tokio::test] #[tokio::test]
async fn stop_selected_uses_session_manager_transition() -> Result<()> { async fn stop_selected_uses_session_manager_transition() -> Result<()> {
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
@@ -5524,6 +5703,7 @@ diff --git a/src/next.rs b/src/next.rs
search_input: None, search_input: None,
search_query: None, search_query: None,
search_scope: SearchScope::SelectedSession, search_scope: SearchScope::SelectedSession,
search_agent_filter: SearchAgentFilter::AllAgents,
search_matches: Vec::new(), search_matches: Vec::new(),
selected_search_match: 0, selected_search_match: 0,
session_table_state, session_table_state,