mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 11:23:32 +08:00
feat: add ecc2 global timeline scope
This commit is contained in:
@@ -78,6 +78,7 @@ pub struct Dashboard {
|
|||||||
output_filter: OutputFilter,
|
output_filter: OutputFilter,
|
||||||
output_time_filter: OutputTimeFilter,
|
output_time_filter: OutputTimeFilter,
|
||||||
timeline_event_filter: TimelineEventFilter,
|
timeline_event_filter: TimelineEventFilter,
|
||||||
|
timeline_scope: SearchScope,
|
||||||
selected_pane: Pane,
|
selected_pane: Pane,
|
||||||
selected_session: usize,
|
selected_session: usize,
|
||||||
show_help: bool,
|
show_help: bool,
|
||||||
@@ -184,6 +185,7 @@ enum TimelineEventType {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct TimelineEvent {
|
struct TimelineEvent {
|
||||||
occurred_at: chrono::DateTime<Utc>,
|
occurred_at: chrono::DateTime<Utc>,
|
||||||
|
session_id: String,
|
||||||
event_type: TimelineEventType,
|
event_type: TimelineEventType,
|
||||||
summary: String,
|
summary: String,
|
||||||
}
|
}
|
||||||
@@ -303,6 +305,7 @@ impl Dashboard {
|
|||||||
output_filter: OutputFilter::All,
|
output_filter: OutputFilter::All,
|
||||||
output_time_filter: OutputTimeFilter::AllTime,
|
output_time_filter: OutputTimeFilter::AllTime,
|
||||||
timeline_event_filter: TimelineEventFilter::All,
|
timeline_event_filter: TimelineEventFilter::All,
|
||||||
|
timeline_scope: SearchScope::SelectedSession,
|
||||||
selected_pane: Pane::Sessions,
|
selected_pane: Pane::Sessions,
|
||||||
selected_session: 0,
|
selected_session: 0,
|
||||||
show_help: false,
|
show_help: false,
|
||||||
@@ -612,7 +615,8 @@ impl Dashboard {
|
|||||||
fn output_title(&self) -> String {
|
fn output_title(&self) -> String {
|
||||||
if self.output_mode == OutputMode::Timeline {
|
if self.output_mode == OutputMode::Timeline {
|
||||||
return format!(
|
return format!(
|
||||||
" Timeline{}{} ",
|
" Timeline{}{}{} ",
|
||||||
|
self.timeline_scope.title_suffix(),
|
||||||
self.timeline_event_filter.title_suffix(),
|
self.timeline_event_filter.title_suffix(),
|
||||||
self.output_time_filter.title_suffix()
|
self.output_time_filter.title_suffix()
|
||||||
);
|
);
|
||||||
@@ -664,31 +668,85 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn empty_timeline_message(&self) -> &'static str {
|
fn empty_timeline_message(&self) -> &'static str {
|
||||||
match (self.timeline_event_filter, self.output_time_filter) {
|
match (
|
||||||
(TimelineEventFilter::All, OutputTimeFilter::AllTime) => {
|
self.timeline_scope,
|
||||||
|
self.timeline_event_filter,
|
||||||
|
self.output_time_filter,
|
||||||
|
) {
|
||||||
|
(SearchScope::AllSessions, TimelineEventFilter::All, OutputTimeFilter::AllTime) => {
|
||||||
|
"No timeline events across all sessions yet."
|
||||||
|
}
|
||||||
|
(
|
||||||
|
SearchScope::AllSessions,
|
||||||
|
TimelineEventFilter::Lifecycle,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No lifecycle events across all sessions yet.",
|
||||||
|
(
|
||||||
|
SearchScope::AllSessions,
|
||||||
|
TimelineEventFilter::Messages,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No message events across all sessions yet.",
|
||||||
|
(
|
||||||
|
SearchScope::AllSessions,
|
||||||
|
TimelineEventFilter::ToolCalls,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No tool-call events across all sessions yet.",
|
||||||
|
(
|
||||||
|
SearchScope::AllSessions,
|
||||||
|
TimelineEventFilter::FileChanges,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No file-change events across all sessions yet.",
|
||||||
|
(SearchScope::AllSessions, TimelineEventFilter::All, _) => {
|
||||||
|
"No timeline events across all sessions in the selected time range."
|
||||||
|
}
|
||||||
|
(SearchScope::AllSessions, TimelineEventFilter::Lifecycle, _) => {
|
||||||
|
"No lifecycle events across all sessions in the selected time range."
|
||||||
|
}
|
||||||
|
(SearchScope::AllSessions, TimelineEventFilter::Messages, _) => {
|
||||||
|
"No message events across all sessions in the selected time range."
|
||||||
|
}
|
||||||
|
(SearchScope::AllSessions, TimelineEventFilter::ToolCalls, _) => {
|
||||||
|
"No tool-call events across all sessions in the selected time range."
|
||||||
|
}
|
||||||
|
(SearchScope::AllSessions, TimelineEventFilter::FileChanges, _) => {
|
||||||
|
"No file-change events across all sessions in the selected time range."
|
||||||
|
}
|
||||||
|
(SearchScope::SelectedSession, TimelineEventFilter::All, OutputTimeFilter::AllTime) => {
|
||||||
"No timeline events for this session yet."
|
"No timeline events for this session yet."
|
||||||
}
|
}
|
||||||
(TimelineEventFilter::Lifecycle, OutputTimeFilter::AllTime) => {
|
(
|
||||||
"No lifecycle events for this session yet."
|
SearchScope::SelectedSession,
|
||||||
|
TimelineEventFilter::Lifecycle,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No lifecycle events for this session yet.",
|
||||||
|
(
|
||||||
|
SearchScope::SelectedSession,
|
||||||
|
TimelineEventFilter::Messages,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No message events for this session yet.",
|
||||||
|
(
|
||||||
|
SearchScope::SelectedSession,
|
||||||
|
TimelineEventFilter::ToolCalls,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No tool-call events for this session yet.",
|
||||||
|
(
|
||||||
|
SearchScope::SelectedSession,
|
||||||
|
TimelineEventFilter::FileChanges,
|
||||||
|
OutputTimeFilter::AllTime,
|
||||||
|
) => "No file-change events for this session yet.",
|
||||||
|
(SearchScope::SelectedSession, TimelineEventFilter::All, _) => {
|
||||||
|
"No timeline events in the selected time range."
|
||||||
}
|
}
|
||||||
(TimelineEventFilter::Messages, OutputTimeFilter::AllTime) => {
|
(SearchScope::SelectedSession, TimelineEventFilter::Lifecycle, _) => {
|
||||||
"No message events for this session yet."
|
|
||||||
}
|
|
||||||
(TimelineEventFilter::ToolCalls, OutputTimeFilter::AllTime) => {
|
|
||||||
"No tool-call events for this session yet."
|
|
||||||
}
|
|
||||||
(TimelineEventFilter::FileChanges, OutputTimeFilter::AllTime) => {
|
|
||||||
"No file-change events for this session yet."
|
|
||||||
}
|
|
||||||
(TimelineEventFilter::All, _) => "No timeline events in the selected time range.",
|
|
||||||
(TimelineEventFilter::Lifecycle, _) => {
|
|
||||||
"No lifecycle events in the selected time range."
|
"No lifecycle events in the selected time range."
|
||||||
}
|
}
|
||||||
(TimelineEventFilter::Messages, _) => "No message events in the selected time range.",
|
(SearchScope::SelectedSession, TimelineEventFilter::Messages, _) => {
|
||||||
(TimelineEventFilter::ToolCalls, _) => {
|
"No message events in the selected time range."
|
||||||
|
}
|
||||||
|
(SearchScope::SelectedSession, TimelineEventFilter::ToolCalls, _) => {
|
||||||
"No tool-call events in the selected time range."
|
"No tool-call events in the selected time range."
|
||||||
}
|
}
|
||||||
(TimelineEventFilter::FileChanges, _) => {
|
(SearchScope::SelectedSession, TimelineEventFilter::FileChanges, _) => {
|
||||||
"No file-change events in the selected time range."
|
"No file-change events in the selected time range."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -813,7 +871,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 natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
|
" [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
|
||||||
self.layout_label(),
|
self.layout_label(),
|
||||||
self.theme_label()
|
self.theme_label()
|
||||||
);
|
);
|
||||||
@@ -905,7 +963,7 @@ impl Dashboard {
|
|||||||
" c Show conflict-resolution protocol for selected conflicted worktree",
|
" c Show conflict-resolution protocol for selected conflicted worktree",
|
||||||
" e Cycle output content filter: all/errors/tool calls/file changes",
|
" e Cycle output content filter: all/errors/tool calls/file changes",
|
||||||
" f Cycle output or timeline time range between all/15m/1h/24h",
|
" f Cycle output or timeline time range between all/15m/1h/24h",
|
||||||
" A Toggle search scope between selected session and all sessions",
|
" A Toggle search or timeline scope between selected session and all sessions",
|
||||||
" o Toggle search agent filter between all agents and selected agent type",
|
" o Toggle search agent filter between all agents and selected agent type",
|
||||||
" 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",
|
||||||
@@ -2046,9 +2104,19 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_search_scope(&mut self) {
|
pub fn toggle_search_scope(&mut self) {
|
||||||
|
if self.output_mode == OutputMode::Timeline {
|
||||||
|
self.timeline_scope = self.timeline_scope.next();
|
||||||
|
self.sync_output_scroll(self.last_output_height.max(1));
|
||||||
|
self.set_operator_note(format!(
|
||||||
|
"timeline scope set to {}",
|
||||||
|
self.timeline_scope.label()
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if self.output_mode != OutputMode::SessionOutput {
|
if self.output_mode != OutputMode::SessionOutput {
|
||||||
self.set_operator_note(
|
self.set_operator_note(
|
||||||
"search scope is only available in session output view".to_string(),
|
"scope toggle is only available in session output or timeline view".to_string(),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2976,14 +3044,21 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn visible_timeline_lines(&self) -> Vec<Line<'static>> {
|
fn visible_timeline_lines(&self) -> Vec<Line<'static>> {
|
||||||
self.selected_timeline_events()
|
let show_session_label = self.timeline_scope == SearchScope::AllSessions;
|
||||||
|
self.timeline_events()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|event| self.timeline_event_filter.matches(event.event_type))
|
.filter(|event| self.timeline_event_filter.matches(event.event_type))
|
||||||
.filter(|event| self.output_time_filter.matches_timestamp(event.occurred_at))
|
.filter(|event| self.output_time_filter.matches_timestamp(event.occurred_at))
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
|
let prefix = if show_session_label {
|
||||||
|
format!("{} ", format_session_id(&event.session_id))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"[{}] {:<11} {}",
|
"[{}] {}{:<11} {}",
|
||||||
event.occurred_at.format("%H:%M:%S"),
|
event.occurred_at.format("%H:%M:%S"),
|
||||||
|
prefix,
|
||||||
event.event_type.label(),
|
event.event_type.label(),
|
||||||
event.summary
|
event.summary
|
||||||
))
|
))
|
||||||
@@ -2991,13 +3066,32 @@ impl Dashboard {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_timeline_events(&self) -> Vec<TimelineEvent> {
|
fn timeline_events(&self) -> Vec<TimelineEvent> {
|
||||||
let Some(session) = self.sessions.get(self.selected_session) else {
|
let mut events = match self.timeline_scope {
|
||||||
return Vec::new();
|
SearchScope::SelectedSession => self
|
||||||
|
.sessions
|
||||||
|
.get(self.selected_session)
|
||||||
|
.map(|session| self.session_timeline_events(session))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
SearchScope::AllSessions => self
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.flat_map(|session| self.session_timeline_events(session))
|
||||||
|
.collect(),
|
||||||
};
|
};
|
||||||
|
events.sort_by(|left, right| {
|
||||||
|
left.occurred_at
|
||||||
|
.cmp(&right.occurred_at)
|
||||||
|
.then_with(|| left.session_id.cmp(&right.session_id))
|
||||||
|
.then_with(|| left.summary.cmp(&right.summary))
|
||||||
|
});
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_timeline_events(&self, session: &Session) -> Vec<TimelineEvent> {
|
||||||
let mut events = vec![TimelineEvent {
|
let mut events = vec![TimelineEvent {
|
||||||
occurred_at: session.created_at,
|
occurred_at: session.created_at,
|
||||||
|
session_id: session.id.clone(),
|
||||||
event_type: TimelineEventType::Lifecycle,
|
event_type: TimelineEventType::Lifecycle,
|
||||||
summary: format!(
|
summary: format!(
|
||||||
"created session as {} for {}",
|
"created session as {} for {}",
|
||||||
@@ -3009,6 +3103,7 @@ impl Dashboard {
|
|||||||
if session.updated_at > session.created_at {
|
if session.updated_at > session.created_at {
|
||||||
events.push(TimelineEvent {
|
events.push(TimelineEvent {
|
||||||
occurred_at: session.updated_at,
|
occurred_at: session.updated_at,
|
||||||
|
session_id: session.id.clone(),
|
||||||
event_type: TimelineEventType::Lifecycle,
|
event_type: TimelineEventType::Lifecycle,
|
||||||
summary: format!("state {} | updated session metadata", session.state),
|
summary: format!("state {} | updated session metadata", session.state),
|
||||||
});
|
});
|
||||||
@@ -3017,6 +3112,7 @@ impl Dashboard {
|
|||||||
if let Some(worktree) = session.worktree.as_ref() {
|
if let Some(worktree) = session.worktree.as_ref() {
|
||||||
events.push(TimelineEvent {
|
events.push(TimelineEvent {
|
||||||
occurred_at: session.updated_at,
|
occurred_at: session.updated_at,
|
||||||
|
session_id: session.id.clone(),
|
||||||
event_type: TimelineEventType::Lifecycle,
|
event_type: TimelineEventType::Lifecycle,
|
||||||
summary: format!(
|
summary: format!(
|
||||||
"attached worktree {} from {}",
|
"attached worktree {} from {}",
|
||||||
@@ -3028,6 +3124,7 @@ impl Dashboard {
|
|||||||
if session.metrics.files_changed > 0 {
|
if session.metrics.files_changed > 0 {
|
||||||
events.push(TimelineEvent {
|
events.push(TimelineEvent {
|
||||||
occurred_at: session.updated_at,
|
occurred_at: session.updated_at,
|
||||||
|
session_id: session.id.clone(),
|
||||||
event_type: TimelineEventType::FileChange,
|
event_type: TimelineEventType::FileChange,
|
||||||
summary: format!("files changed {}", session.metrics.files_changed),
|
summary: format!("files changed {}", session.metrics.files_changed),
|
||||||
});
|
});
|
||||||
@@ -3045,6 +3142,7 @@ impl Dashboard {
|
|||||||
};
|
};
|
||||||
TimelineEvent {
|
TimelineEvent {
|
||||||
occurred_at: message.timestamp,
|
occurred_at: message.timestamp,
|
||||||
|
session_id: session.id.clone(),
|
||||||
event_type: TimelineEventType::Message,
|
event_type: TimelineEventType::Message,
|
||||||
summary: format!(
|
summary: format!(
|
||||||
"{direction} {} {} | {}",
|
"{direction} {} {} | {}",
|
||||||
@@ -3066,6 +3164,7 @@ impl Dashboard {
|
|||||||
events.extend(tool_logs.into_iter().filter_map(|entry| {
|
events.extend(tool_logs.into_iter().filter_map(|entry| {
|
||||||
parse_rfc3339_to_utc(&entry.timestamp).map(|occurred_at| TimelineEvent {
|
parse_rfc3339_to_utc(&entry.timestamp).map(|occurred_at| TimelineEvent {
|
||||||
occurred_at,
|
occurred_at,
|
||||||
|
session_id: session.id.clone(),
|
||||||
event_type: TimelineEventType::ToolCall,
|
event_type: TimelineEventType::ToolCall,
|
||||||
summary: format!(
|
summary: format!(
|
||||||
"tool {} | {}ms | {}",
|
"tool {} | {}ms | {}",
|
||||||
@@ -3075,8 +3174,6 @@ impl Dashboard {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
events.sort_by_key(|event| event.occurred_at);
|
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5503,6 +5600,77 @@ mod tests {
|
|||||||
assert!(!rendered.contains("state running"));
|
assert!(!rendered.contains("state running"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timeline_scope_all_sessions_renders_cross_session_events() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut focus = sample_session(
|
||||||
|
"focus-12345678",
|
||||||
|
"planner",
|
||||||
|
SessionState::Running,
|
||||||
|
Some("ecc/focus"),
|
||||||
|
512,
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
focus.created_at = now - chrono::Duration::hours(2);
|
||||||
|
focus.updated_at = now - chrono::Duration::minutes(5);
|
||||||
|
|
||||||
|
let mut review = sample_session(
|
||||||
|
"review-87654321",
|
||||||
|
"reviewer",
|
||||||
|
SessionState::Idle,
|
||||||
|
Some("ecc/review"),
|
||||||
|
256,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
review.created_at = now - chrono::Duration::hours(1);
|
||||||
|
review.updated_at = now - chrono::Duration::minutes(3);
|
||||||
|
review.metrics.files_changed = 2;
|
||||||
|
|
||||||
|
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
|
||||||
|
dashboard.db.insert_session(&focus).unwrap();
|
||||||
|
dashboard.db.insert_session(&review).unwrap();
|
||||||
|
dashboard
|
||||||
|
.db
|
||||||
|
.insert_tool_log(
|
||||||
|
"focus-12345678",
|
||||||
|
"bash",
|
||||||
|
"cargo test -q",
|
||||||
|
"ok",
|
||||||
|
240,
|
||||||
|
0.2,
|
||||||
|
&(now - chrono::Duration::minutes(4)).to_rfc3339(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
dashboard
|
||||||
|
.db
|
||||||
|
.insert_tool_log(
|
||||||
|
"review-87654321",
|
||||||
|
"git",
|
||||||
|
"git status --short",
|
||||||
|
"ok",
|
||||||
|
120,
|
||||||
|
0.1,
|
||||||
|
&(now - chrono::Duration::minutes(2)).to_rfc3339(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
dashboard.toggle_timeline_mode();
|
||||||
|
|
||||||
|
dashboard.toggle_search_scope();
|
||||||
|
|
||||||
|
assert_eq!(dashboard.timeline_scope, SearchScope::AllSessions);
|
||||||
|
assert_eq!(
|
||||||
|
dashboard.operator_note.as_deref(),
|
||||||
|
Some("timeline scope set to all sessions")
|
||||||
|
);
|
||||||
|
assert_eq!(dashboard.output_title(), " Timeline all sessions ");
|
||||||
|
|
||||||
|
let rendered = dashboard.rendered_output_text(180, 30);
|
||||||
|
assert!(rendered.contains("focus-12"));
|
||||||
|
assert!(rendered.contains("review-8"));
|
||||||
|
assert!(rendered.contains("tool bash"));
|
||||||
|
assert!(rendered.contains("tool git"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worktree_diff_columns_split_removed_and_added_lines() {
|
fn worktree_diff_columns_split_removed_and_added_lines() {
|
||||||
let patch = "\
|
let patch = "\
|
||||||
@@ -8337,6 +8505,7 @@ diff --git a/src/next.rs b/src/next.rs
|
|||||||
output_filter: OutputFilter::All,
|
output_filter: OutputFilter::All,
|
||||||
output_time_filter: OutputTimeFilter::AllTime,
|
output_time_filter: OutputTimeFilter::AllTime,
|
||||||
timeline_event_filter: TimelineEventFilter::All,
|
timeline_event_filter: TimelineEventFilter::All,
|
||||||
|
timeline_scope: SearchScope::SelectedSession,
|
||||||
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