mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 11:23:32 +08:00
feat: add ecc2 stderr output filter
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user