From 3b700c8715477ee68b68b1476ae9fb3be094d8de Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:10:51 -0700 Subject: [PATCH] feat: add ecc2 output time filters --- ecc2/src/session/output.rs | 31 ++++- ecc2/src/session/store.rs | 12 +- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 256 ++++++++++++++++++++++++++++--------- 4 files changed, 230 insertions(+), 70 deletions(-) diff --git a/ecc2/src/session/output.rs b/ecc2/src/session/output.rs index 6cae21f3..d7ac8745 100644 --- a/ecc2/src/session/output.rs +++ b/ecc2/src/session/output.rs @@ -32,6 +32,31 @@ impl OutputStream { pub struct OutputLine { pub stream: OutputStream, pub text: String, + pub timestamp: String, +} + +impl OutputLine { + pub fn new( + stream: OutputStream, + text: impl Into, + timestamp: impl Into, + ) -> Self { + Self { + stream, + text: text.into(), + timestamp: timestamp.into(), + } + } + + pub fn with_current_timestamp(stream: OutputStream, text: impl Into) -> Self { + Self::new(stream, text, chrono::Utc::now().to_rfc3339()) + } + + pub fn occurred_at(&self) -> Option> { + chrono::DateTime::parse_from_rfc3339(&self.timestamp) + .ok() + .map(|timestamp| timestamp.with_timezone(&chrono::Utc)) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -70,10 +95,7 @@ impl SessionOutputStore { } pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into) { - let line = OutputLine { - stream, - text: text.into(), - }; + let line = OutputLine::with_current_timestamp(stream, text); { let mut buffers = self.lock_buffers(); @@ -145,5 +167,6 @@ mod tests { assert_eq!(event.session_id, "session-1"); assert_eq!(event.line.stream, OutputStream::Stderr); assert_eq!(event.line.text, "problem"); + assert!(event.line.occurred_at().is_some()); } } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 2cb906b8..128c7c4e 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -961,9 +961,9 @@ impl StateStore { pub fn get_output_lines(&self, session_id: &str, limit: usize) -> Result> { let mut stmt = self.conn.prepare( - "SELECT stream, line + "SELECT stream, line, timestamp FROM ( - SELECT id, stream, line + SELECT id, stream, line, timestamp FROM session_output WHERE session_id = ?1 ORDER BY id DESC @@ -976,11 +976,13 @@ impl StateStore { .query_map(rusqlite::params![session_id, limit as i64], |row| { let stream: String = row.get(0)?; let text: String = row.get(1)?; + let timestamp: String = row.get(2)?; - Ok(OutputLine { - stream: OutputStream::from_db_value(&stream), + Ok(OutputLine::new( + OutputStream::from_db_value(&stream), text, - }) + timestamp, + )) })? .collect::, _>>()?; diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index ec3e3aa1..c47a92c6 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -74,6 +74,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), + (_, KeyCode::Char('f')) => dashboard.cycle_output_time_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 c3d64595..9ddb75ec 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1,3 +1,4 @@ +use chrono::{Duration, Utc}; use ratatui::{ prelude::*, widgets::{ @@ -72,6 +73,7 @@ pub struct Dashboard { selected_merge_readiness: Option, output_mode: OutputMode, output_filter: OutputFilter, + output_time_filter: OutputTimeFilter, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -123,6 +125,14 @@ enum OutputFilter { ErrorsOnly, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputTimeFilter { + AllTime, + Last15Minutes, + LastHour, + Last24Hours, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -201,6 +211,7 @@ impl Dashboard { selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, + output_time_filter: OutputTimeFilter::AllTime, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -472,7 +483,11 @@ impl Dashboard { } fn output_title(&self) -> String { - let filter = self.output_filter_label(); + let filter = format!( + "{}{}", + self.output_filter.title_suffix(), + self.output_time_filter.title_suffix() + ); if let Some(input) = self.search_input.as_ref() { return format!(" Output{filter} /{input}_ "); } @@ -490,17 +505,14 @@ impl Dashboard { format!(" Output{filter} ") } - 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.", + match (self.output_filter, self.output_time_filter) { + (OutputFilter::All, OutputTimeFilter::AllTime) => "Waiting for session output...", + (OutputFilter::ErrorsOnly, OutputTimeFilter::AllTime) => { + "No stderr output for this session yet." + } + (OutputFilter::All, _) => "No output lines in the selected time range.", + (OutputFilter::ErrorsOnly, _) => "No stderr output in the selected time range.", } } @@ -611,7 +623,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 [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 ", + " [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 [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() ); @@ -683,6 +695,7 @@ impl Dashboard { " 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", + " f Cycle output time filter between all/15m/1h/24h", " 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", @@ -1724,6 +1737,23 @@ impl Dashboard { )); } + pub fn cycle_output_time_filter(&mut self) { + if self.output_mode != OutputMode::SessionOutput { + self.set_operator_note( + "output time filters are only available in session output view".to_string(), + ); + return; + } + + self.output_time_filter = self.output_time_filter.next(); + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note(format!( + "output time filter set to {}", + self.output_time_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() { @@ -2192,7 +2222,9 @@ impl Dashboard { fn visible_output_lines(&self) -> Vec<&OutputLine> { self.selected_output_lines() .iter() - .filter(|line| self.output_filter.matches(line.stream)) + .filter(|line| { + self.output_filter.matches(line.stream) && self.output_time_filter.matches(line) + }) .collect() } @@ -2864,6 +2896,60 @@ impl OutputFilter { OutputFilter::ErrorsOnly => "errors", } } + + fn title_suffix(self) -> &'static str { + match self { + OutputFilter::All => "", + OutputFilter::ErrorsOnly => " errors", + } + } +} + +impl OutputTimeFilter { + fn next(self) -> Self { + match self { + Self::AllTime => Self::Last15Minutes, + Self::Last15Minutes => Self::LastHour, + Self::LastHour => Self::Last24Hours, + Self::Last24Hours => Self::AllTime, + } + } + + fn matches(self, line: &OutputLine) -> bool { + match self { + Self::AllTime => true, + Self::Last15Minutes => line + .occurred_at() + .map(|timestamp| timestamp >= Utc::now() - Duration::minutes(15)) + .unwrap_or(false), + Self::LastHour => line + .occurred_at() + .map(|timestamp| timestamp >= Utc::now() - Duration::hours(1)) + .unwrap_or(false), + Self::Last24Hours => line + .occurred_at() + .map(|timestamp| timestamp >= Utc::now() - Duration::hours(24)) + .unwrap_or(false), + } + } + + fn label(self) -> &'static str { + match self { + Self::AllTime => "all time", + Self::Last15Minutes => "last 15m", + Self::LastHour => "last 1h", + Self::Last24Hours => "last 24h", + } + } + + fn title_suffix(self) -> &'static str { + match self { + Self::AllTime => "", + Self::Last15Minutes => " last 15m", + Self::LastHour => " last 1h", + Self::Last24Hours => " last 24h", + } + } } impl SessionSummary { @@ -3320,10 +3406,7 @@ mod tests { ); dashboard.session_output_cache.insert( "focus-12345678".to_string(), - vec![OutputLine { - stream: OutputStream::Stdout, - text: "last useful output".to_string(), - }], + vec![test_output_line(OutputStream::Stdout, "last useful output")], ); dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string()); dashboard.selected_diff_preview = vec![ @@ -4160,18 +4243,9 @@ diff --git a/src/next.rs b/src/next.rs dashboard.session_output_cache.insert( "focus-12345678".to_string(), vec![ - OutputLine { - stream: OutputStream::Stdout, - text: "alpha".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "beta".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "alpha tail".to_string(), - }, + test_output_line(OutputStream::Stdout, "alpha"), + test_output_line(OutputStream::Stdout, "beta"), + test_output_line(OutputStream::Stdout, "alpha tail"), ], ); dashboard.last_output_height = 2; @@ -4207,18 +4281,9 @@ diff --git a/src/next.rs b/src/next.rs dashboard.session_output_cache.insert( "focus-12345678".to_string(), vec![ - OutputLine { - stream: OutputStream::Stdout, - text: "alpha-1".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "beta".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "alpha-2".to_string(), - }, + test_output_line(OutputStream::Stdout, "alpha-1"), + test_output_line(OutputStream::Stdout, "beta"), + test_output_line(OutputStream::Stdout, "alpha-2"), ], ); dashboard.search_query = Some(r"alpha-\d".to_string()); @@ -4304,14 +4369,8 @@ diff --git a/src/next.rs b/src/next.rs 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(), - }, + test_output_line(OutputStream::Stdout, "stdout line"), + test_output_line(OutputStream::Stderr, "stderr line"), ], ); @@ -4342,18 +4401,9 @@ diff --git a/src/next.rs b/src/next.rs 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(), - }, + test_output_line(OutputStream::Stdout, "alpha stdout"), + test_output_line(OutputStream::Stderr, "alpha stderr"), + test_output_line(OutputStream::Stderr, "beta stderr"), ], ); dashboard.output_filter = OutputFilter::ErrorsOnly; @@ -4366,6 +4416,73 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr"); } + #[test] + fn cycle_output_time_filter_keeps_only_recent_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![ + test_output_line_minutes_ago(OutputStream::Stdout, "recent line", 5), + test_output_line_minutes_ago(OutputStream::Stdout, "older line", 45), + test_output_line_minutes_ago(OutputStream::Stdout, "stale line", 180), + ], + ); + + dashboard.cycle_output_time_filter(); + + assert_eq!( + dashboard.output_time_filter, + OutputTimeFilter::Last15Minutes + ); + assert_eq!(dashboard.visible_output_text(), "recent line"); + assert_eq!(dashboard.output_title(), " Output last 15m "); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("output time filter set to last 15m") + ); + } + + #[test] + fn search_matches_respect_time_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![ + test_output_line_minutes_ago(OutputStream::Stdout, "alpha recent", 10), + test_output_line_minutes_ago(OutputStream::Stdout, "beta recent", 10), + test_output_line_minutes_ago(OutputStream::Stdout, "alpha stale", 180), + ], + ); + dashboard.output_time_filter = OutputTimeFilter::Last15Minutes; + 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 recent\nbeta recent"); + } + #[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())); @@ -5056,6 +5173,22 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.theme_palette().row_highlight_bg, Color::Gray); } + fn test_output_line(stream: OutputStream, text: &str) -> OutputLine { + OutputLine::new(stream, text, Utc::now().to_rfc3339()) + } + + fn test_output_line_minutes_ago( + stream: OutputStream, + text: &str, + minutes_ago: i64, + ) -> OutputLine { + OutputLine::new( + stream, + text, + (Utc::now() - chrono::Duration::minutes(minutes_ago)).to_rfc3339(), + ) + } + fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); @@ -5093,6 +5226,7 @@ diff --git a/src/next.rs b/src/next.rs selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, + output_time_filter: OutputTimeFilter::AllTime, selected_pane: Pane::Sessions, selected_session, show_help: false,