feat: add ecc2 context graph dashboard view

This commit is contained in:
Affaan Mustafa
2026-04-10 04:10:08 -07:00
parent 08f0e86d76
commit 4adb3324ef
2 changed files with 577 additions and 34 deletions

View File

@@ -98,9 +98,13 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(),
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
(_, KeyCode::Char('K')) => dashboard.toggle_context_graph_mode(),
(_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(),
(_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(),
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
(_, KeyCode::Char('E')) if dashboard.is_context_graph_mode() => {
dashboard.cycle_graph_entity_filter()
}
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
(_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(),

View File

@@ -117,6 +117,7 @@ pub struct Dashboard {
selected_git_patch_hunk_offsets_split: Vec<usize>,
selected_git_patch_hunk: usize,
output_mode: OutputMode,
graph_entity_filter: GraphEntityFilter,
output_filter: OutputFilter,
output_time_filter: OutputTimeFilter,
timeline_event_filter: TimelineEventFilter,
@@ -182,12 +183,22 @@ enum Pane {
enum OutputMode {
SessionOutput,
Timeline,
ContextGraph,
WorktreeDiff,
ConflictProtocol,
GitStatus,
GitPatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GraphEntityFilter {
All,
Decisions,
Files,
Functions,
Sessions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiffViewMode {
Split,
@@ -246,6 +257,12 @@ struct SearchMatch {
line_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GraphDisplayLine {
session_id: String,
text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PrPromptSpec {
title: String,
@@ -535,6 +552,7 @@ impl Dashboard {
selected_git_patch_hunk_offsets_split: Vec::new(),
selected_git_patch_hunk: 0,
output_mode: OutputMode::SessionOutput,
graph_entity_filter: GraphEntityFilter::All,
output_filter: OutputFilter::All,
output_time_filter: OutputTimeFilter::AllTime,
timeline_event_filter: TimelineEventFilter::All,
@@ -817,6 +835,21 @@ impl Dashboard {
};
(self.output_title(), content)
}
OutputMode::ContextGraph => {
let lines = self.visible_graph_lines();
let content = if lines.is_empty() {
Text::from(self.empty_graph_message())
} else if self.search_query.is_some() {
self.render_searchable_graph(&lines)
} else {
Text::from(
lines.into_iter()
.map(|line| Line::from(line.text))
.collect::<Vec<_>>(),
)
};
(self.output_title(), content)
}
OutputMode::WorktreeDiff => {
let content = if let Some(patch) = self.selected_diff_patch.as_ref() {
build_unified_diff_text(patch, self.theme_palette())
@@ -924,6 +957,25 @@ impl Dashboard {
);
}
if self.output_mode == OutputMode::ContextGraph {
let scope = self.search_scope.title_suffix();
let filter = self.graph_entity_filter.title_suffix();
let time = self.output_time_filter.title_suffix();
if let Some(input) = self.search_input.as_ref() {
return format!(" Graph{scope}{filter}{time} /{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!(" Graph{scope}{filter}{time} /{query} {current}/{total} ");
}
return format!(" Graph{scope}{filter}{time} ");
}
if self.output_mode == OutputMode::WorktreeDiff {
return format!(
" Diff{}{} ",
@@ -1116,6 +1168,34 @@ impl Dashboard {
}
}
fn empty_graph_message(&self) -> &'static str {
match (
self.search_scope,
self.graph_entity_filter,
self.output_time_filter,
) {
(SearchScope::SelectedSession, GraphEntityFilter::All, OutputTimeFilter::AllTime) => {
"No graph entities for this session yet."
}
(_, GraphEntityFilter::Decisions, OutputTimeFilter::AllTime) => {
"No decision graph entities in the current scope yet."
}
(_, GraphEntityFilter::Files, OutputTimeFilter::AllTime) => {
"No file graph entities in the current scope yet."
}
(_, GraphEntityFilter::Functions, OutputTimeFilter::AllTime) => {
"No function graph entities in the current scope yet."
}
(_, GraphEntityFilter::Sessions, OutputTimeFilter::AllTime) => {
"No session graph entities in the current scope yet."
}
(SearchScope::AllSessions, GraphEntityFilter::All, OutputTimeFilter::AllTime) => {
"No graph entities across all sessions yet."
}
(_, _, _) => "No graph entities in the selected filter/time range.",
}
}
fn render_searchable_output(&self, lines: &[&OutputLine]) -> Text<'static> {
let Some(query) = self.search_query.as_deref() else {
return Text::from(
@@ -1147,6 +1227,39 @@ impl Dashboard {
self.theme_palette(),
)
})
.collect::<Vec<_>>(),
)
}
fn render_searchable_graph(&self, lines: &[GraphDisplayLine]) -> Text<'static> {
let Some(query) = self.search_query.as_deref() else {
return Text::from(
lines
.iter()
.map(|line| Line::from(line.text.clone()))
.collect::<Vec<_>>(),
);
};
let active_match = self.search_matches.get(self.selected_search_match);
Text::from(
lines
.iter()
.enumerate()
.map(|(index, line)| {
highlight_output_line(
&line.text,
query,
active_match
.map(|search_match| {
search_match.session_id == line.session_id
&& search_match.line_index == index
})
.unwrap_or(false),
self.theme_palette(),
)
})
.collect::<Vec<_>>(),
)
}
@@ -1380,10 +1493,11 @@ impl Dashboard {
" I Jump to the next unread approval/conflict target session".to_string(),
" g Auto-dispatch unread handoffs across lead sessions".to_string(),
" G Dispatch then rebalance backlog across lead teams".to_string(),
" K Toggle selected-session context graph view".to_string(),
" h Collapse the focused non-session pane".to_string(),
" H Restore all collapsed panes".to_string(),
" y Toggle selected-session timeline view".to_string(),
" E Cycle timeline event filter".to_string(),
" E Cycle timeline event filter or graph entity filter".to_string(),
" v Toggle selected worktree diff or selected-file patch in output pane"
.to_string(),
" z Toggle selected worktree git status in output pane".to_string(),
@@ -1396,7 +1510,7 @@ impl Dashboard {
.to_string(),
" e Cycle output content filter: all/errors/tool calls/file changes".to_string(),
" f Cycle output or timeline time range between all/15m/1h/24h".to_string(),
" A Toggle search or timeline scope between selected session and all sessions"
" A Toggle search, graph, or timeline scope between selected session and all sessions"
.to_string(),
" o Toggle search agent filter between all agents and selected agent type"
.to_string(),
@@ -1430,7 +1544,7 @@ impl Dashboard {
" k/↑ Scroll up".to_string(),
" [ or ] Focus previous/next delegate in lead Metrics board".to_string(),
" Enter Open focused delegate from lead Metrics board".to_string(),
" / Search current session output".to_string(),
" / Search session output or graph lines".to_string(),
" n/N Next/previous search match when search is active".to_string(),
" Esc Clear active search or cancel search input".to_string(),
" +/= Increase pane size and persist it".to_string(),
@@ -2113,6 +2227,11 @@ impl Dashboard {
self.reset_output_view();
self.set_operator_note("showing session output".to_string());
}
OutputMode::ContextGraph => {
self.output_mode = OutputMode::SessionOutput;
self.reset_output_view();
self.set_operator_note("showing session output".to_string());
}
OutputMode::ConflictProtocol => {
self.output_mode = OutputMode::SessionOutput;
self.reset_output_view();
@@ -3057,6 +3176,10 @@ impl Dashboard {
self.search_query.is_some()
}
pub fn is_context_graph_mode(&self) -> bool {
self.output_mode == OutputMode::ContextGraph
}
pub fn has_active_completion_popup(&self) -> bool {
self.active_completion_popup.is_some()
}
@@ -3092,9 +3215,27 @@ impl Dashboard {
return;
}
if self.output_mode == OutputMode::ContextGraph {
self.search_scope = self.search_scope.next();
self.recompute_search_matches();
self.sync_output_scroll(self.last_output_height.max(1));
if self.search_query.is_some() {
self.set_operator_note(format!(
"graph scope set to {} | {} match(es)",
self.search_scope.label(),
self.search_matches.len()
));
} else {
self.set_operator_note(format!("graph scope set to {}", self.search_scope.label()));
}
return;
}
if self.output_mode != OutputMode::SessionOutput {
self.set_operator_note(
"scope toggle is only available in session output or timeline view".to_string(),
"scope toggle is only available in session output, graph, or timeline view"
.to_string(),
);
return;
}
@@ -3154,13 +3295,20 @@ impl Dashboard {
return;
}
if self.output_mode != OutputMode::SessionOutput {
self.set_operator_note("search is only available in session output view".to_string());
if !matches!(self.output_mode, OutputMode::SessionOutput | OutputMode::ContextGraph) {
self.set_operator_note(
"search is only available in session output or graph 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());
let mode = if self.output_mode == OutputMode::ContextGraph {
"graph search"
} else {
"search"
};
self.set_operator_note(format!("{mode} mode | type a query and press Enter"));
}
pub fn push_input_char(&mut self, ch: char) {
@@ -3327,10 +3475,20 @@ impl Dashboard {
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"));
let mode = if self.output_mode == OutputMode::ContextGraph {
"graph search"
} else {
"search"
};
self.set_operator_note(format!("{mode} /{query} found no matches"));
} else {
let mode = if self.output_mode == OutputMode::ContextGraph {
"graph search"
} else {
"search"
};
self.set_operator_note(format!(
"search /{query} matched {} line(s) across {} session(s) | n/N navigate matches",
"{mode} /{query} matched {} line(s) across {} session(s) | n/N navigate matches",
self.search_matches.len(),
self.search_match_session_count()
));
@@ -3551,7 +3709,12 @@ impl Dashboard {
self.search_matches.clear();
self.selected_search_match = 0;
if had_query || had_input {
self.set_operator_note("cleared output search".to_string());
let mode = if self.output_mode == OutputMode::ContextGraph {
"graph search"
} else {
"output search"
};
self.set_operator_note(format!("cleared {mode}"));
}
}
@@ -3601,23 +3764,27 @@ impl Dashboard {
pub fn cycle_output_time_filter(&mut self) {
if !matches!(
self.output_mode,
OutputMode::SessionOutput | OutputMode::Timeline
OutputMode::SessionOutput | OutputMode::Timeline | OutputMode::ContextGraph
) {
self.set_operator_note(
"time filters are only available in session output or timeline view".to_string(),
"time filters are only available in session output, graph, or timeline view"
.to_string(),
);
return;
}
self.output_time_filter = self.output_time_filter.next();
if self.output_mode == OutputMode::SessionOutput {
if matches!(
self.output_mode,
OutputMode::SessionOutput | OutputMode::ContextGraph
) {
self.recompute_search_matches();
}
self.sync_output_scroll(self.last_output_height.max(1));
let note_prefix = if self.output_mode == OutputMode::Timeline {
"timeline range"
} else {
"output time filter"
let note_prefix = match self.output_mode {
OutputMode::Timeline => "timeline range",
OutputMode::ContextGraph => "graph range",
_ => "output time filter",
};
self.set_operator_note(format!(
"{note_prefix} set to {}",
@@ -3641,6 +3808,41 @@ impl Dashboard {
));
}
pub fn toggle_context_graph_mode(&mut self) {
match self.output_mode {
OutputMode::ContextGraph => {
self.output_mode = OutputMode::SessionOutput;
self.reset_output_view();
self.set_operator_note("showing session output".to_string());
}
_ => {
self.output_mode = OutputMode::ContextGraph;
self.selected_pane = Pane::Output;
self.output_follow = false;
self.output_scroll_offset = 0;
self.recompute_search_matches();
self.set_operator_note("showing selected session context graph".to_string());
}
}
}
pub fn cycle_graph_entity_filter(&mut self) {
if self.output_mode != OutputMode::ContextGraph {
self.set_operator_note(
"graph entity filters are only available in context graph view".to_string(),
);
return;
}
self.graph_entity_filter = self.graph_entity_filter.next();
self.recompute_search_matches();
self.sync_output_scroll(self.last_output_height.max(1));
self.set_operator_note(format!(
"graph filter set to {}",
self.graph_entity_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() {
@@ -4842,6 +5044,97 @@ impl Dashboard {
.unwrap_or_default()
}
fn visible_graph_lines(&self) -> Vec<GraphDisplayLine> {
let session_scope = match self.search_scope {
SearchScope::SelectedSession => self.selected_session_id(),
SearchScope::AllSessions => None,
};
let entity_type = self.graph_entity_filter.entity_type();
let entities = self
.db
.list_context_entities(session_scope, entity_type, 48)
.unwrap_or_default();
let show_session_label = self.search_scope == SearchScope::AllSessions;
entities
.into_iter()
.filter(|entity| self.output_time_filter.matches_timestamp(entity.updated_at))
.flat_map(|entity| self.graph_lines_for_entity(entity, show_session_label))
.collect()
}
fn graph_lines_for_entity(
&self,
entity: crate::session::ContextGraphEntity,
show_session_label: bool,
) -> Vec<GraphDisplayLine> {
let session_id = entity.session_id.clone().unwrap_or_default();
let session_label = if show_session_label {
if session_id.is_empty() {
"global ".to_string()
} else {
format!("{} ", format_session_id(&session_id))
}
} else {
String::new()
};
let entity_title = format!(
"[{}] {}{:<8} {}",
entity.updated_at.format("%H:%M:%S"),
session_label,
entity.entity_type,
entity.name
);
let mut lines = vec![GraphDisplayLine {
session_id: session_id.clone(),
text: entity_title,
}];
if let Some(path) = entity.path.as_ref() {
lines.push(GraphDisplayLine {
session_id: session_id.clone(),
text: format!(" path {}", truncate_for_dashboard(path, 96)),
});
}
if !entity.summary.trim().is_empty() {
lines.push(GraphDisplayLine {
session_id: session_id.clone(),
text: format!(
" summary {}",
truncate_for_dashboard(&entity.summary, 96)
),
});
}
if let Ok(Some(detail)) = self.db.get_context_entity_detail(entity.id, 2) {
for relation in detail.outgoing {
lines.push(GraphDisplayLine {
session_id: session_id.clone(),
text: format!(
" -> {} {}:{}",
relation.relation_type,
relation.to_entity_type,
truncate_for_dashboard(&relation.to_entity_name, 72)
),
});
}
for relation in detail.incoming {
lines.push(GraphDisplayLine {
session_id: session_id.clone(),
text: format!(
" <- {} {}:{}",
relation.relation_type,
relation.from_entity_type,
truncate_for_dashboard(&relation.from_entity_name, 72)
),
});
}
}
lines
}
fn visible_git_status_lines(&self) -> Vec<Line<'static>> {
self.selected_git_status_entries
.iter()
@@ -5066,22 +5359,34 @@ impl Dashboard {
return;
};
self.search_matches = self
.search_target_session_ids()
.into_iter()
.flat_map(|session_id| {
self.visible_output_lines_for_session(session_id)
.into_iter()
.enumerate()
.filter_map(|(index, line)| {
regex.is_match(&line.text).then_some(SearchMatch {
session_id: session_id.to_string(),
line_index: index,
})
self.search_matches = if self.output_mode == OutputMode::ContextGraph {
self.visible_graph_lines()
.into_iter()
.enumerate()
.filter_map(|(index, line)| {
regex.is_match(&line.text).then_some(SearchMatch {
session_id: line.session_id,
line_index: index,
})
.collect::<Vec<_>>()
})
.collect();
})
.collect()
} else {
self.search_target_session_ids()
.into_iter()
.flat_map(|session_id| {
self.visible_output_lines_for_session(session_id)
.into_iter()
.enumerate()
.filter_map(|(index, line)| {
regex.is_match(&line.text).then_some(SearchMatch {
session_id: session_id.to_string(),
line_index: index,
})
})
.collect::<Vec<_>>()
})
.collect()
};
if self.search_matches.is_empty() {
self.selected_search_match = 0;
@@ -5100,7 +5405,9 @@ impl Dashboard {
return;
};
if self.selected_session_id() != Some(search_match.session_id.as_str()) {
if !search_match.session_id.is_empty()
&& self.selected_session_id() != Some(search_match.session_id.as_str())
{
self.sync_selection_by_id(Some(&search_match.session_id));
self.sync_selected_output();
self.sync_selected_diff();
@@ -5126,8 +5433,13 @@ impl Dashboard {
self.selected_search_match.min(total.saturating_sub(1)) + 1
};
let mode = if self.output_mode == OutputMode::ContextGraph {
"graph search"
} else {
"search"
};
format!(
"search /{query} match {current}/{total} | {}",
"{mode} /{query} match {current}/{total} | {}",
self.search_scope.label()
)
}
@@ -5135,6 +5447,7 @@ impl Dashboard {
fn search_match_session_count(&self) -> usize {
self.search_matches
.iter()
.filter(|search_match| !search_match.session_id.is_empty())
.map(|search_match| search_match.session_id.as_str())
.collect::<HashSet<_>>()
.len()
@@ -5224,6 +5537,8 @@ impl Dashboard {
self.active_patch_text()
.map(|patch| patch.lines().count())
.unwrap_or(0)
} else if self.output_mode == OutputMode::ContextGraph {
self.visible_graph_lines().len()
} else if self.output_mode == OutputMode::Timeline {
self.visible_timeline_lines().len()
} else {
@@ -6705,6 +7020,48 @@ impl TimelineEventFilter {
}
}
impl GraphEntityFilter {
fn next(self) -> Self {
match self {
Self::All => Self::Decisions,
Self::Decisions => Self::Files,
Self::Files => Self::Functions,
Self::Functions => Self::Sessions,
Self::Sessions => Self::All,
}
}
fn entity_type(self) -> Option<&'static str> {
match self {
Self::All => None,
Self::Decisions => Some("decision"),
Self::Files => Some("file"),
Self::Functions => Some("function"),
Self::Sessions => Some("session"),
}
}
fn label(self) -> &'static str {
match self {
Self::All => "all entities",
Self::Decisions => "decisions",
Self::Files => "files",
Self::Functions => "functions",
Self::Sessions => "sessions",
}
}
fn title_suffix(self) -> &'static str {
match self {
Self::All => "",
Self::Decisions => " decisions",
Self::Files => " files",
Self::Functions => " functions",
Self::Sessions => " sessions",
}
}
}
impl TimelineEventType {
fn label(self) -> &'static str {
match self {
@@ -9583,6 +9940,187 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
assert!(rendered.contains("tool git"));
}
#[test]
fn toggle_context_graph_mode_renders_selected_session_entities_and_relations() -> Result<()> {
let session = sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
);
let mut dashboard = test_dashboard(vec![session.clone()], 0);
dashboard.db.insert_session(&session)?;
let file = dashboard.db.upsert_context_entity(
Some(&session.id),
"file",
"dashboard.rs",
Some("ecc2/src/tui/dashboard.rs"),
"dashboard renderer",
&std::collections::BTreeMap::new(),
)?;
let function = dashboard.db.upsert_context_entity(
Some(&session.id),
"function",
"render_output",
None,
"renders the output pane",
&std::collections::BTreeMap::new(),
)?;
dashboard.db.upsert_context_relation(
Some(&session.id),
file.id,
function.id,
"contains",
"output rendering path",
)?;
dashboard.toggle_context_graph_mode();
assert_eq!(dashboard.output_mode, OutputMode::ContextGraph);
assert_eq!(
dashboard.operator_note.as_deref(),
Some("showing selected session context graph")
);
let rendered = dashboard.rendered_output_text(180, 30);
assert!(rendered.contains("Graph"));
assert!(rendered.contains("dashboard.rs"));
assert!(rendered.contains("summary dashboard renderer"));
assert!(rendered.contains("-> contains function:render_output"));
Ok(())
}
#[test]
fn cycle_graph_entity_filter_limits_rendered_entities() -> Result<()> {
let session = sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
);
let mut dashboard = test_dashboard(vec![session.clone()], 0);
dashboard.db.insert_session(&session)?;
dashboard.db.insert_decision(
&session.id,
"Use sqlite graph sync",
&[],
"Keeps shared memory queryable",
)?;
dashboard.db.upsert_context_entity(
Some(&session.id),
"file",
"dashboard.rs",
Some("ecc2/src/tui/dashboard.rs"),
"dashboard renderer",
&std::collections::BTreeMap::new(),
)?;
dashboard.toggle_context_graph_mode();
dashboard.cycle_graph_entity_filter();
assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Decisions);
assert_eq!(dashboard.output_title(), " Graph decisions ");
let rendered = dashboard.rendered_output_text(180, 30);
assert!(rendered.contains("Use sqlite graph sync"));
assert!(!rendered.contains("dashboard.rs"));
dashboard.cycle_graph_entity_filter();
assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Files);
assert_eq!(dashboard.output_title(), " Graph files ");
let rendered = dashboard.rendered_output_text(180, 30);
assert!(rendered.contains("dashboard.rs"));
assert!(!rendered.contains("Use sqlite graph sync"));
Ok(())
}
#[test]
fn graph_scope_all_sessions_renders_cross_session_entities() -> Result<()> {
let focus = sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
);
let review = sample_session(
"review-87654321",
"reviewer",
SessionState::Running,
None,
1,
1,
);
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
dashboard.db.insert_session(&focus)?;
dashboard.db.insert_session(&review)?;
dashboard.db.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?;
dashboard.db.insert_decision(&review.id, "Beta graph path", &[], "review path")?;
dashboard.toggle_context_graph_mode();
dashboard.toggle_search_scope();
assert_eq!(dashboard.search_scope, SearchScope::AllSessions);
assert_eq!(
dashboard.operator_note.as_deref(),
Some("graph scope set to all sessions")
);
assert_eq!(dashboard.output_title(), " Graph all sessions ");
let rendered = dashboard.rendered_output_text(180, 30);
assert!(rendered.contains("focus-12"));
assert!(rendered.contains("review-8"));
assert!(rendered.contains("Alpha graph path"));
assert!(rendered.contains("Beta graph path"));
Ok(())
}
#[test]
fn graph_search_matches_and_switches_selected_session() -> Result<()> {
let focus = sample_session(
"focus-12345678",
"planner",
SessionState::Running,
None,
1,
1,
);
let review = sample_session(
"review-87654321",
"reviewer",
SessionState::Running,
None,
1,
1,
);
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
dashboard.db.insert_session(&focus)?;
dashboard.db.insert_session(&review)?;
dashboard.db.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?;
dashboard.db.insert_decision(&review.id, "alpha remote graph", &[], "review path")?;
dashboard.toggle_context_graph_mode();
dashboard.toggle_search_scope();
dashboard.begin_search();
for ch in "alpha.*".chars() {
dashboard.push_input_char(ch);
}
dashboard.submit_search();
assert_eq!(dashboard.search_matches.len(), 2);
let first_session = dashboard.selected_session_id().map(str::to_string);
dashboard.next_search_match();
assert_eq!(
dashboard.operator_note.as_deref(),
Some("graph search /alpha.* match 2/2 | all sessions")
);
assert_ne!(dashboard.selected_session_id().map(str::to_string), first_session);
Ok(())
}
#[test]
fn worktree_diff_columns_split_removed_and_added_lines() {
let patch = "\
@@ -13343,6 +13881,7 @@ diff --git a/src/lib.rs b/src/lib.rs
selected_git_patch_hunk_offsets_split: Vec::new(),
selected_git_patch_hunk: 0,
output_mode: OutputMode::SessionOutput,
graph_entity_filter: GraphEntityFilter::All,
output_filter: OutputFilter::All,
output_time_filter: OutputTimeFilter::AllTime,
timeline_event_filter: TimelineEventFilter::All,