From 8440181001d282480a1c77a941f6bfa805eaaace Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 03:57:12 -0700 Subject: [PATCH] feat: add ecc2 output search mode --- ecc2/src/tui/app.rs | 26 +++ ecc2/src/tui/dashboard.rs | 423 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 434 insertions(+), 15 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 1ecc3072..e0f29f0d 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -27,6 +27,24 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { + if dashboard.is_search_mode() { + match (key.modifiers, key.code) { + (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (_, KeyCode::Esc) => dashboard.cancel_search_input(), + (_, KeyCode::Enter) => dashboard.submit_search(), + (_, KeyCode::Backspace) => dashboard.pop_search_char(), + (modifiers, KeyCode::Char(ch)) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + dashboard.push_search_char(ch); + } + _ => {} + } + + continue; + } + match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, (_, KeyCode::Char('q')) => break, @@ -38,6 +56,14 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('-')) => dashboard.decrease_pane_size(), (_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(), (_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(), + (_, KeyCode::Char('/')) => dashboard.begin_search(), + (_, KeyCode::Esc) => dashboard.clear_search(), + (_, KeyCode::Char('n')) if dashboard.has_active_search() => { + dashboard.next_search_match() + } + (_, KeyCode::Char('N')) if dashboard.has_active_search() => { + dashboard.prev_search_match() + } (_, KeyCode::Char('n')) => dashboard.new_session().await, (_, KeyCode::Char('a')) => dashboard.assign_selected().await, (_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index eb996ba8..c1244892 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -78,6 +78,10 @@ pub struct Dashboard { output_scroll_offset: usize, last_output_height: usize, pane_size_percent: u16, + search_input: Option, + search_query: Option, + search_matches: Vec, + selected_search_match: usize, session_table_state: TableState, } @@ -196,6 +200,10 @@ impl Dashboard { output_scroll_offset: 0, last_output_height: 0, pane_size_percent, + search_input: None, + search_query: None, + search_matches: Vec::new(), + selected_search_match: 0, session_table_state, }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); @@ -366,15 +374,18 @@ impl Dashboard { OutputMode::SessionOutput => { let lines = self.selected_output_lines(); let content = if lines.is_empty() { - "Waiting for session output...".to_string() + Text::from("Waiting for session output...") + } else if self.search_query.is_some() { + self.render_searchable_output(lines) } else { - lines - .iter() - .map(|line| line.text.as_str()) - .collect::>() - .join("\n") + Text::from( + lines + .iter() + .map(|line| Line::from(line.text.clone())) + .collect::>(), + ) }; - (" Output ", content) + (self.output_title(), content) } OutputMode::WorktreeDiff => { let content = self @@ -390,19 +401,19 @@ impl Dashboard { .unwrap_or_else(|| { "No worktree diff available for the selected session.".to_string() }); - (" Diff ", content) + (" Diff ".to_string(), Text::from(content)) } OutputMode::ConflictProtocol => { let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { "No conflicted worktree available for the selected session.".to_string() }); - (" Conflict Protocol ", content) + (" Conflict Protocol ".to_string(), Text::from(content)) } } } else { ( - " Output ", - "No sessions. Press 'n' to start one.".to_string(), + self.output_title(), + Text::from("No sessions. Press 'n' to start one."), ) }; @@ -451,6 +462,50 @@ impl Dashboard { frame.render_widget(additions, column_chunks[1]); } + fn output_title(&self) -> String { + if let Some(input) = self.search_input.as_ref() { + return format!(" Output /{input}_ "); + } + + if let Some(query) = self.search_query.as_ref() { + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + return format!(" Output /{query} {current}/{total} "); + } + + " Output ".to_string() + } + + fn render_searchable_output(&self, lines: &[OutputLine]) -> Text<'static> { + let Some(query) = self.search_query.as_deref() else { + return Text::from( + lines + .iter() + .map(|line| Line::from(line.text.clone())) + .collect::>(), + ); + }; + + Text::from( + lines + .iter() + .enumerate() + .map(|(index, line)| { + highlight_output_line( + &line.text, + query, + self.search_matches.get(self.selected_search_match).copied() == Some(index), + self.theme_palette(), + ) + }) + .collect::>(), + ) + } + fn render_metrics(&self, frame: &mut Frame, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -531,15 +586,32 @@ impl Dashboard { } fn render_status_bar(&self, frame: &mut Frame, area: Rect) { - let 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 ", self.layout_label(), self.theme_label() ); - let text = if let Some(note) = self.operator_note.as_ref() { - format!(" {} |{}", truncate_for_dashboard(note, 96), text) + + let search_prefix = if let Some(input) = self.search_input.as_ref() { + format!(" /{input}_ | [Enter] apply [Esc] cancel |") + } else if let Some(query) = self.search_query.as_ref() { + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + format!(" /{query} {current}/{total} | [n/N] navigate [Esc] clear |") } else { - text + String::new() + }; + + let text = if self.search_input.is_some() || self.search_query.is_some() { + format!(" {search_prefix}") + } else if let Some(note) = self.operator_note.as_ref() { + format!(" {} |{}", truncate_for_dashboard(note, 96), base_text) + } else { + base_text }; let aggregate = self.aggregate_usage(); let (summary_text, summary_style) = self.aggregate_cost_summary(); @@ -603,6 +675,9 @@ impl Dashboard { " S-Tab Previous pane", " j/↓ Scroll down", " k/↑ Scroll up", + " / Search current session output", + " n/N Next/previous search match when search is active", + " Esc Clear active search or cancel search input", " +/= Increase pane size and persist it", " - Decrease pane size and persist it", " r Refresh", @@ -1503,6 +1578,101 @@ impl Dashboard { self.show_help = !self.show_help; } + pub fn is_search_mode(&self) -> bool { + self.search_input.is_some() + } + + pub fn has_active_search(&self) -> bool { + self.search_query.is_some() + } + + pub fn begin_search(&mut self) { + if self.output_mode != OutputMode::SessionOutput { + self.set_operator_note("search is only available in session output view".to_string()); + return; + } + + self.search_input = Some(self.search_query.clone().unwrap_or_default()); + self.set_operator_note("search mode | type a query and press Enter".to_string()); + } + + pub fn push_search_char(&mut self, ch: char) { + if let Some(input) = self.search_input.as_mut() { + input.push(ch); + } + } + + pub fn pop_search_char(&mut self) { + if let Some(input) = self.search_input.as_mut() { + input.pop(); + } + } + + pub fn cancel_search_input(&mut self) { + if self.search_input.take().is_some() { + self.set_operator_note("search input cancelled".to_string()); + } + } + + pub fn submit_search(&mut self) { + let Some(input) = self.search_input.take() else { + return; + }; + + let query = input.trim().to_string(); + if query.is_empty() { + self.clear_search(); + return; + } + + self.search_query = Some(query.clone()); + self.recompute_search_matches(); + if self.search_matches.is_empty() { + self.set_operator_note(format!("search /{query} found no matches")); + } else { + self.set_operator_note(format!( + "search /{query} matched {} line(s) | n/N navigate matches", + self.search_matches.len() + )); + } + } + + pub fn clear_search(&mut self) { + let had_query = self.search_query.take().is_some(); + let had_input = self.search_input.take().is_some(); + self.search_matches.clear(); + self.selected_search_match = 0; + if had_query || had_input { + self.set_operator_note("cleared output search".to_string()); + } + } + + pub fn next_search_match(&mut self) { + if self.search_matches.is_empty() { + self.set_operator_note("no output search matches to navigate".to_string()); + return; + } + + self.selected_search_match = (self.selected_search_match + 1) % self.search_matches.len(); + self.focus_selected_search_match(); + self.set_operator_note(self.search_navigation_note()); + } + + pub fn prev_search_match(&mut self) { + if self.search_matches.is_empty() { + self.set_operator_note("no output search matches to navigate".to_string()); + return; + } + + self.selected_search_match = if self.selected_search_match == 0 { + self.search_matches.len() - 1 + } else { + self.selected_search_match - 1 + }; + self.focus_selected_search_match(); + self.set_operator_note(self.search_navigation_note()); + } + pub fn toggle_auto_dispatch_policy(&mut self) { self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; match self.cfg.save() { @@ -1730,6 +1900,8 @@ impl Dashboard { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.output_scroll_offset = 0; self.output_follow = true; + self.search_matches.clear(); + self.selected_search_match = 0; return; }; @@ -1737,6 +1909,7 @@ impl Dashboard { Ok(lines) => { self.output_store.replace_lines(&session_id, lines.clone()); self.session_output_cache.insert(session_id, lines); + self.recompute_search_matches(); } Err(error) => { tracing::warn!("Failed to load session output: {error}"); @@ -1965,6 +2138,54 @@ impl Dashboard { .unwrap_or(&[]) } + fn recompute_search_matches(&mut self) { + let Some(query) = self.search_query.clone() else { + self.search_matches.clear(); + self.selected_search_match = 0; + return; + }; + + self.search_matches = self + .selected_output_lines() + .iter() + .enumerate() + .filter_map(|(index, line)| line_matches_query(&line.text, &query).then_some(index)) + .collect(); + + if self.search_matches.is_empty() { + self.selected_search_match = 0; + return; + } + + self.selected_search_match = self + .selected_search_match + .min(self.search_matches.len().saturating_sub(1)); + self.focus_selected_search_match(); + } + + fn focus_selected_search_match(&mut self) { + let Some(line_index) = self.search_matches.get(self.selected_search_match).copied() else { + return; + }; + + self.output_follow = false; + let viewport_height = self.last_output_height.max(1); + let offset = line_index.saturating_sub(viewport_height.saturating_sub(1) / 2); + self.output_scroll_offset = offset.min(self.max_output_scroll()); + } + + fn search_navigation_note(&self) -> String { + let query = self.search_query.as_deref().unwrap_or_default(); + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + + format!("search /{query} match {current}/{total}") + } + fn sync_output_scroll(&mut self, viewport_height: usize) { self.last_output_height = viewport_height.max(1); let max_scroll = self.max_output_scroll(); @@ -2738,6 +2959,60 @@ fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 { configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT) } +fn line_matches_query(text: &str, query: &str) -> bool { + if query.is_empty() { + return false; + } + + text.contains(query) +} + +fn highlight_output_line( + text: &str, + query: &str, + is_current_match: bool, + palette: ThemePalette, +) -> Line<'static> { + if query.is_empty() { + return Line::from(text.to_string()); + } + + let mut spans = Vec::new(); + let mut cursor = 0; + let mut search_start = 0; + + while let Some(relative_index) = text[search_start..].find(query) { + let start = search_start + relative_index; + let end = start + query.len(); + + if start > cursor { + spans.push(Span::raw(text[cursor..start].to_string())); + } + + let match_style = if is_current_match { + Style::default() + .bg(palette.accent) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(Color::Yellow).fg(Color::Black) + }; + spans.push(Span::styled(text[start..end].to_string(), match_style)); + cursor = end; + search_start = end; + } + + if cursor < text.len() { + spans.push(Span::raw(text[cursor..].to_string())); + } + + if spans.is_empty() { + Line::from(text.to_string()) + } else { + Line::from(spans) + } +} + fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { let mut removals = Vec::new(); let mut additions = Vec::new(); @@ -3783,6 +4058,120 @@ diff --git a/src/next.rs b/src/next.rs Ok(()) } + #[test] + fn submit_search_tracks_matches_and_sets_navigation_note() { + 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".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "beta".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "alpha tail".to_string(), + }, + ], + ); + dashboard.last_output_height = 2; + + dashboard.begin_search(); + for ch in "alpha".chars() { + dashboard.push_search_char(ch); + } + dashboard.submit_search(); + + assert_eq!(dashboard.search_query.as_deref(), Some("alpha")); + assert_eq!(dashboard.search_matches, vec![0, 2]); + assert_eq!(dashboard.selected_search_match, 0); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("search /alpha matched 2 line(s) | n/N navigate matches") + ); + } + + #[test] + fn next_search_match_wraps_and_updates_scroll_offset() { + 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".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "beta".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "alpha tail".to_string(), + }, + ], + ); + dashboard.search_query = Some("alpha".to_string()); + dashboard.last_output_height = 1; + dashboard.recompute_search_matches(); + + dashboard.next_search_match(); + assert_eq!(dashboard.selected_search_match, 1); + assert_eq!(dashboard.output_scroll_offset, 2); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("search /alpha match 2/2") + ); + + dashboard.next_search_match(); + assert_eq!(dashboard.selected_search_match, 0); + assert_eq!(dashboard.output_scroll_offset, 0); + } + + #[test] + fn clear_search_resets_active_query_and_matches() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.search_input = Some("draft".to_string()); + dashboard.search_query = Some("alpha".to_string()); + dashboard.search_matches = vec![1, 3]; + dashboard.selected_search_match = 1; + + dashboard.clear_search(); + + assert!(dashboard.search_input.is_none()); + assert!(dashboard.search_query.is_none()); + assert!(dashboard.search_matches.is_empty()); + assert_eq!(dashboard.selected_search_match, 0); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("cleared output search") + ); + } + #[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())); @@ -4516,6 +4905,10 @@ diff --git a/src/next.rs b/src/next.rs output_follow: true, output_scroll_offset: 0, last_output_height: 0, + search_input: None, + search_query: None, + search_matches: Vec::new(), + selected_search_match: 0, session_table_state, } }