feat: add ecc2 output content filters

This commit is contained in:
Affaan Mustafa
2026-04-09 04:26:06 -07:00
parent bab03bd8af
commit 15e05d96ad

View File

@@ -125,6 +125,8 @@ enum OutputMode {
enum OutputFilter {
All,
ErrorsOnly,
ToolCallsOnly,
FileChangesOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -535,8 +537,18 @@ impl Dashboard {
(OutputFilter::ErrorsOnly, OutputTimeFilter::AllTime) => {
"No stderr output for this session yet."
}
(OutputFilter::ToolCallsOnly, OutputTimeFilter::AllTime) => {
"No tool-call output for this session yet."
}
(OutputFilter::FileChangesOnly, OutputTimeFilter::AllTime) => {
"No file-change 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.",
(OutputFilter::ToolCallsOnly, _) => "No tool-call output in the selected time range.",
(OutputFilter::FileChangesOnly, _) => {
"No file-change output in the selected time range."
}
}
}
@@ -656,7 +668,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 time [f]ilter search scope [A] agent filter [o] [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 cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [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()
);
@@ -735,7 +747,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",
" e Cycle output content filter: all/errors/tool calls/file changes",
" f Cycle output time filter between all/15m/1h/24h",
" A Toggle search scope between selected session and all sessions",
" o Toggle search agent filter between all agents and selected agent type",
@@ -1826,10 +1838,7 @@ impl Dashboard {
return;
}
self.output_filter = match self.output_filter {
OutputFilter::All => OutputFilter::ErrorsOnly,
OutputFilter::ErrorsOnly => OutputFilter::All,
};
self.output_filter = self.output_filter.next();
self.recompute_search_matches();
self.sync_output_scroll(self.last_output_height.max(1));
self.set_operator_note(format!(
@@ -2363,8 +2372,7 @@ impl Dashboard {
lines
.iter()
.filter(|line| {
self.output_filter.matches(line.stream)
&& self.output_time_filter.matches(line)
self.output_filter.matches(line) && self.output_time_filter.matches(line)
})
.collect()
})
@@ -3082,10 +3090,21 @@ impl Pane {
}
impl OutputFilter {
fn matches(self, stream: OutputStream) -> bool {
fn next(self) -> Self {
match self {
Self::All => Self::ErrorsOnly,
Self::ErrorsOnly => Self::ToolCallsOnly,
Self::ToolCallsOnly => Self::FileChangesOnly,
Self::FileChangesOnly => Self::All,
}
}
fn matches(self, line: &OutputLine) -> bool {
match self {
OutputFilter::All => true,
OutputFilter::ErrorsOnly => stream == OutputStream::Stderr,
OutputFilter::ErrorsOnly => line.stream == OutputStream::Stderr,
OutputFilter::ToolCallsOnly => looks_like_tool_call(&line.text),
OutputFilter::FileChangesOnly => looks_like_file_change(&line.text),
}
}
@@ -3093,6 +3112,8 @@ impl OutputFilter {
match self {
OutputFilter::All => "all",
OutputFilter::ErrorsOnly => "errors",
OutputFilter::ToolCallsOnly => "tool calls",
OutputFilter::FileChangesOnly => "file changes",
}
}
@@ -3100,10 +3121,97 @@ impl OutputFilter {
match self {
OutputFilter::All => "",
OutputFilter::ErrorsOnly => " errors",
OutputFilter::ToolCallsOnly => " tool calls",
OutputFilter::FileChangesOnly => " file changes",
}
}
}
fn looks_like_tool_call(text: &str) -> bool {
let lower = text.trim().to_ascii_lowercase();
if lower.is_empty() {
return false;
}
const TOOL_PREFIXES: &[&str] = &[
"tool ",
"tool:",
"[tool",
"tool call",
"calling tool",
"running tool",
"invoking tool",
"using tool",
"read(",
"write(",
"edit(",
"multi_edit(",
"bash(",
"grep(",
"glob(",
"search(",
"ls(",
"apply_patch(",
];
TOOL_PREFIXES.iter().any(|prefix| lower.starts_with(prefix))
}
fn looks_like_file_change(text: &str) -> bool {
let lower = text.trim().to_ascii_lowercase();
if lower.is_empty() {
return false;
}
if lower.contains("applied patch")
|| lower.contains("patch applied")
|| lower.starts_with("diff --git ")
{
return true;
}
const FILE_CHANGE_VERBS: &[&str] = &[
"updated ",
"created ",
"deleted ",
"renamed ",
"modified ",
"wrote ",
"editing ",
"edited ",
"writing ",
];
FILE_CHANGE_VERBS
.iter()
.any(|prefix| lower.starts_with(prefix) && contains_path_like_token(text))
}
fn contains_path_like_token(text: &str) -> bool {
text.split_whitespace().any(|token| {
let trimmed = token.trim_matches(|ch: char| {
matches!(
ch,
'[' | ']' | '(' | ')' | '{' | '}' | ',' | ':' | ';' | '"' | '\''
)
});
trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.starts_with("./")
|| trimmed.starts_with("../")
|| trimmed
.rsplit_once('.')
.map(|(stem, ext)| {
!stem.is_empty()
&& !ext.is_empty()
&& ext.len() <= 10
&& ext.chars().all(|ch| ch.is_ascii_alphanumeric())
})
.unwrap_or(false)
})
}
impl OutputTimeFilter {
fn next(self) -> Self {
match self {
@@ -4658,6 +4766,55 @@ diff --git a/src/next.rs b/src/next.rs
);
}
#[test]
fn toggle_output_filter_cycles_tool_calls_and_file_changes() {
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(OutputStream::Stdout, "normal output"),
test_output_line(OutputStream::Stdout, "Read(src/lib.rs)"),
test_output_line(OutputStream::Stdout, "Updated ecc2/src/tui/dashboard.rs"),
test_output_line(OutputStream::Stderr, "stderr line"),
],
);
dashboard.toggle_output_filter();
assert_eq!(dashboard.output_filter, OutputFilter::ErrorsOnly);
assert_eq!(dashboard.visible_output_text(), "stderr line");
dashboard.toggle_output_filter();
assert_eq!(dashboard.output_filter, OutputFilter::ToolCallsOnly);
assert_eq!(dashboard.visible_output_text(), "Read(src/lib.rs)");
assert_eq!(dashboard.output_title(), " Output tool calls ");
assert_eq!(
dashboard.operator_note.as_deref(),
Some("output filter set to tool calls")
);
dashboard.toggle_output_filter();
assert_eq!(dashboard.output_filter, OutputFilter::FileChangesOnly);
assert_eq!(
dashboard.visible_output_text(),
"Updated ecc2/src/tui/dashboard.rs"
);
assert_eq!(dashboard.output_title(), " Output file changes ");
assert_eq!(
dashboard.operator_note.as_deref(),
Some("output filter set to file changes")
);
}
#[test]
fn search_matches_respect_error_only_filter() {
let mut dashboard = test_dashboard(
@@ -4695,6 +4852,86 @@ diff --git a/src/next.rs b/src/next.rs
assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr");
}
#[test]
fn search_matches_respect_tool_call_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(OutputStream::Stdout, "alpha normal"),
test_output_line(OutputStream::Stdout, "Read(alpha.rs)"),
test_output_line(OutputStream::Stdout, "Write(beta.rs)"),
],
);
dashboard.output_filter = OutputFilter::ToolCallsOnly;
dashboard.search_query = Some("alpha.*".to_string());
dashboard.last_output_height = 1;
dashboard.recompute_search_matches();
assert_eq!(
dashboard.search_matches,
vec![SearchMatch {
session_id: "focus-12345678".to_string(),
line_index: 0,
}]
);
assert_eq!(
dashboard.visible_output_text(),
"Read(alpha.rs)\nWrite(beta.rs)"
);
}
#[test]
fn search_matches_respect_file_change_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(OutputStream::Stdout, "alpha normal"),
test_output_line(OutputStream::Stdout, "Updated alpha.rs"),
test_output_line(OutputStream::Stdout, "Renamed beta.rs to gamma.rs"),
],
);
dashboard.output_filter = OutputFilter::FileChangesOnly;
dashboard.search_query = Some("alpha.*".to_string());
dashboard.last_output_height = 1;
dashboard.recompute_search_matches();
assert_eq!(
dashboard.search_matches,
vec![SearchMatch {
session_id: "focus-12345678".to_string(),
line_index: 0,
}]
);
assert_eq!(
dashboard.visible_output_text(),
"Updated alpha.rs\nRenamed beta.rs to gamma.rs"
);
}
#[test]
fn cycle_output_time_filter_keeps_only_recent_lines() {
let mut dashboard = test_dashboard(