From 077f46b7775928556aa8697bf54beeb38ba1b1c8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:04:25 -0700 Subject: [PATCH] feat: add ecc2 stderr output filter --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 181 +++++++++++++++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 13 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index e0f29f0d..ec3e3aa1 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -73,6 +73,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_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_ready_worktrees().await, (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index d88c6bf6..c3d64595 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -13,13 +13,13 @@ use crate::comms; use crate::config::{Config, PaneLayout, Theme}; use crate::observability::ToolLogEntry; 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::{Session, SessionMessage, SessionState}; use crate::worktree; -#[cfg(test)] -use crate::session::output::OutputStream; #[cfg(test)] use crate::session::{SessionMetrics, WorktreeInfo}; @@ -71,6 +71,7 @@ pub struct Dashboard { selected_conflict_protocol: Option, selected_merge_readiness: Option, output_mode: OutputMode, + output_filter: OutputFilter, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -116,6 +117,12 @@ enum OutputMode { ConflictProtocol, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFilter { + All, + ErrorsOnly, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -193,6 +200,7 @@ impl Dashboard { selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, + output_filter: OutputFilter::All, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -373,11 +381,11 @@ impl Dashboard { let (title, content) = if self.sessions.get(self.selected_session).is_some() { match self.output_mode { OutputMode::SessionOutput => { - let lines = self.selected_output_lines(); + let lines = self.visible_output_lines(); 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() { - self.render_searchable_output(lines) + self.render_searchable_output(&lines) } else { Text::from( lines @@ -464,8 +472,9 @@ impl Dashboard { } fn output_title(&self) -> String { + let filter = self.output_filter_label(); 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() { @@ -475,13 +484,27 @@ impl Dashboard { } else { 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 { return Text::from( lines @@ -588,7 +611,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { 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.theme_label() ); @@ -659,6 +682,7 @@ impl Dashboard { " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", " 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 all ready inactive worktrees and clean them up", " l Cycle pane layout and persist it", @@ -1680,6 +1704,26 @@ impl Dashboard { 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) { self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; match self.cfg.save() { @@ -2145,6 +2189,13 @@ impl Dashboard { .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) { let Some(query) = self.search_query.clone() else { self.search_matches.clear(); @@ -2159,7 +2210,7 @@ impl Dashboard { }; self.search_matches = self - .selected_output_lines() + .visible_output_lines() .iter() .enumerate() .filter_map(|(index, line)| regex.is_match(&line.text).then_some(index)) @@ -2211,11 +2262,20 @@ impl Dashboard { } fn max_output_scroll(&self) -> usize { - self.selected_output_lines() + self.visible_output_lines() .len() .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::>() + .join("\n") + } + fn reset_output_view(&mut self) { self.output_follow = true; 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 { fn from_sessions( 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] async fn stop_selected_uses_session_manager_transition() -> Result<()> { 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_merge_readiness: None, output_mode: OutputMode::SessionOutput, + output_filter: OutputFilter::All, selected_pane: Pane::Sessions, selected_session, show_help: false,