feat: add ecc2 stderr output filter

This commit is contained in:
Affaan Mustafa
2026-04-09 04:04:25 -07:00
parent 8fc40da739
commit 077f46b777
2 changed files with 169 additions and 13 deletions

View File

@@ -73,6 +73,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
(_, KeyCode::Char('e')) => dashboard.toggle_output_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

@@ -13,13 +13,13 @@ use crate::comms;
use crate::config::{Config, PaneLayout, Theme}; use crate::config::{Config, PaneLayout, Theme};
use crate::observability::ToolLogEntry; use crate::observability::ToolLogEntry;
use crate::session::manager; use crate::session::manager;
use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; use crate::session::output::{
OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT,
};
use crate::session::store::{DaemonActivity, StateStore}; use crate::session::store::{DaemonActivity, StateStore};
use crate::session::{Session, SessionMessage, SessionState}; use crate::session::{Session, SessionMessage, SessionState};
use crate::worktree; use crate::worktree;
#[cfg(test)]
use crate::session::output::OutputStream;
#[cfg(test)] #[cfg(test)]
use crate::session::{SessionMetrics, WorktreeInfo}; use crate::session::{SessionMetrics, WorktreeInfo};
@@ -71,6 +71,7 @@ pub struct Dashboard {
selected_conflict_protocol: Option<String>, selected_conflict_protocol: Option<String>,
selected_merge_readiness: Option<worktree::MergeReadiness>, selected_merge_readiness: Option<worktree::MergeReadiness>,
output_mode: OutputMode, output_mode: OutputMode,
output_filter: OutputFilter,
selected_pane: Pane, selected_pane: Pane,
selected_session: usize, selected_session: usize,
show_help: bool, show_help: bool,
@@ -116,6 +117,12 @@ enum OutputMode {
ConflictProtocol, ConflictProtocol,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputFilter {
All,
ErrorsOnly,
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct PaneAreas { struct PaneAreas {
sessions: Rect, sessions: Rect,
@@ -193,6 +200,7 @@ impl Dashboard {
selected_conflict_protocol: None, selected_conflict_protocol: None,
selected_merge_readiness: None, selected_merge_readiness: None,
output_mode: OutputMode::SessionOutput, output_mode: OutputMode::SessionOutput,
output_filter: OutputFilter::All,
selected_pane: Pane::Sessions, selected_pane: Pane::Sessions,
selected_session: 0, selected_session: 0,
show_help: false, show_help: false,
@@ -373,11 +381,11 @@ impl Dashboard {
let (title, content) = if self.sessions.get(self.selected_session).is_some() { let (title, content) = if self.sessions.get(self.selected_session).is_some() {
match self.output_mode { match self.output_mode {
OutputMode::SessionOutput => { OutputMode::SessionOutput => {
let lines = self.selected_output_lines(); let lines = self.visible_output_lines();
let content = if lines.is_empty() { let content = if lines.is_empty() {
Text::from("Waiting for session output...") Text::from(self.empty_output_message())
} else if self.search_query.is_some() { } else if self.search_query.is_some() {
self.render_searchable_output(lines) self.render_searchable_output(&lines)
} else { } else {
Text::from( Text::from(
lines lines
@@ -464,8 +472,9 @@ impl Dashboard {
} }
fn output_title(&self) -> String { fn output_title(&self) -> String {
let filter = self.output_filter_label();
if let Some(input) = self.search_input.as_ref() { if let Some(input) = self.search_input.as_ref() {
return format!(" Output /{input}_ "); return format!(" Output{filter} /{input}_ ");
} }
if let Some(query) = self.search_query.as_ref() { if let Some(query) = self.search_query.as_ref() {
@@ -475,13 +484,27 @@ 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 /{query} {current}/{total} "); return format!(" Output{filter} /{query} {current}/{total} ");
} }
" Output ".to_string() format!(" Output{filter} ")
} }
fn render_searchable_output(&self, lines: &[OutputLine]) -> Text<'static> { fn output_filter_label(&self) -> &'static str {
match self.output_filter {
OutputFilter::All => "",
OutputFilter::ErrorsOnly => " errors",
}
}
fn empty_output_message(&self) -> &'static str {
match self.output_filter {
OutputFilter::All => "Waiting for session output...",
OutputFilter::ErrorsOnly => "No stderr output for this session yet.",
}
}
fn render_searchable_output(&self, lines: &[&OutputLine]) -> Text<'static> {
let Some(query) = self.search_query.as_deref() else { let Some(query) = self.search_query.as_deref() else {
return Text::from( return Text::from(
lines lines
@@ -588,7 +611,7 @@ 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 [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 [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()
); );
@@ -659,6 +682,7 @@ impl Dashboard {
" G Dispatch then rebalance backlog across lead teams", " G Dispatch then rebalance backlog across lead teams",
" v Toggle selected worktree diff in output pane", " v Toggle selected worktree diff in output pane",
" c Show conflict-resolution protocol for selected conflicted worktree", " c Show conflict-resolution protocol for selected conflicted worktree",
" e Toggle output filter between all lines and stderr only",
" 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",
@@ -1680,6 +1704,26 @@ impl Dashboard {
self.set_operator_note(self.search_navigation_note()); self.set_operator_note(self.search_navigation_note());
} }
pub fn toggle_output_filter(&mut self) {
if self.output_mode != OutputMode::SessionOutput {
self.set_operator_note(
"output filters are only available in session output view".to_string(),
);
return;
}
self.output_filter = match self.output_filter {
OutputFilter::All => OutputFilter::ErrorsOnly,
OutputFilter::ErrorsOnly => OutputFilter::All,
};
self.recompute_search_matches();
self.sync_output_scroll(self.last_output_height.max(1));
self.set_operator_note(format!(
"output filter set to {}",
self.output_filter.label()
));
}
pub fn toggle_auto_dispatch_policy(&mut self) { pub fn toggle_auto_dispatch_policy(&mut self) {
self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs;
match self.cfg.save() { match self.cfg.save() {
@@ -2145,6 +2189,13 @@ impl Dashboard {
.unwrap_or(&[]) .unwrap_or(&[])
} }
fn visible_output_lines(&self) -> Vec<&OutputLine> {
self.selected_output_lines()
.iter()
.filter(|line| self.output_filter.matches(line.stream))
.collect()
}
fn recompute_search_matches(&mut self) { fn recompute_search_matches(&mut self) {
let Some(query) = self.search_query.clone() else { let Some(query) = self.search_query.clone() else {
self.search_matches.clear(); self.search_matches.clear();
@@ -2159,7 +2210,7 @@ impl Dashboard {
}; };
self.search_matches = self self.search_matches = self
.selected_output_lines() .visible_output_lines()
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(index, line)| regex.is_match(&line.text).then_some(index)) .filter_map(|(index, line)| regex.is_match(&line.text).then_some(index))
@@ -2211,11 +2262,20 @@ impl Dashboard {
} }
fn max_output_scroll(&self) -> usize { fn max_output_scroll(&self) -> usize {
self.selected_output_lines() self.visible_output_lines()
.len() .len()
.saturating_sub(self.last_output_height.max(1)) .saturating_sub(self.last_output_height.max(1))
} }
#[cfg(test)]
fn visible_output_text(&self) -> String {
self.visible_output_lines()
.iter()
.map(|line| line.text.clone())
.collect::<Vec<_>>()
.join("\n")
}
fn reset_output_view(&mut self) { fn reset_output_view(&mut self) {
self.output_follow = true; self.output_follow = true;
self.output_scroll_offset = 0; self.output_scroll_offset = 0;
@@ -2790,6 +2850,22 @@ impl Pane {
} }
} }
impl OutputFilter {
fn matches(self, stream: OutputStream) -> bool {
match self {
OutputFilter::All => true,
OutputFilter::ErrorsOnly => stream == OutputStream::Stderr,
}
}
fn label(self) -> &'static str {
match self {
OutputFilter::All => "all",
OutputFilter::ErrorsOnly => "errors",
}
}
}
impl SessionSummary { impl SessionSummary {
fn from_sessions( fn from_sessions(
sessions: &[Session], sessions: &[Session],
@@ -4212,6 +4288,84 @@ diff --git a/src/next.rs b/src/next.rs
); );
} }
#[test]
fn toggle_output_filter_keeps_only_stderr_lines() {
let mut dashboard = test_dashboard(
vec![sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
)],
0,
);
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![
OutputLine {
stream: OutputStream::Stdout,
text: "stdout line".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "stderr line".to_string(),
},
],
);
dashboard.toggle_output_filter();
assert_eq!(dashboard.output_filter, OutputFilter::ErrorsOnly);
assert_eq!(dashboard.visible_output_text(), "stderr line");
assert_eq!(dashboard.output_title(), " Output errors ");
assert_eq!(
dashboard.operator_note.as_deref(),
Some("output filter set to errors")
);
}
#[test]
fn search_matches_respect_error_only_filter() {
let mut dashboard = test_dashboard(
vec![sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
)],
0,
);
dashboard.session_output_cache.insert(
"focus-12345678".to_string(),
vec![
OutputLine {
stream: OutputStream::Stdout,
text: "alpha stdout".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "alpha stderr".to_string(),
},
OutputLine {
stream: OutputStream::Stderr,
text: "beta stderr".to_string(),
},
],
);
dashboard.output_filter = OutputFilter::ErrorsOnly;
dashboard.search_query = Some("alpha.*".to_string());
dashboard.last_output_height = 1;
dashboard.recompute_search_matches();
assert_eq!(dashboard.search_matches, vec![0]);
assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr");
}
#[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()));
@@ -4938,6 +5092,7 @@ diff --git a/src/next.rs b/src/next.rs
selected_conflict_protocol: None, selected_conflict_protocol: None,
selected_merge_readiness: None, selected_merge_readiness: None,
output_mode: OutputMode::SessionOutput, output_mode: OutputMode::SessionOutput,
output_filter: OutputFilter::All,
selected_pane: Pane::Sessions, selected_pane: Pane::Sessions,
selected_session, selected_session,
show_help: false, show_help: false,