mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-09 19:03:28 +08:00
feat: add ecc2 output search mode
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -78,6 +78,10 @@ pub struct Dashboard {
|
||||
output_scroll_offset: usize,
|
||||
last_output_height: usize,
|
||||
pane_size_percent: u16,
|
||||
search_input: Option<String>,
|
||||
search_query: Option<String>,
|
||||
search_matches: Vec<usize>,
|
||||
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::<Vec<_>>()
|
||||
.join("\n")
|
||||
Text::from(
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| Line::from(line.text.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
(" 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::<Vec<_>>(),
|
||||
);
|
||||
};
|
||||
|
||||
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::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user