1use crate::event::{EventIndexRow, EventKind};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, PartialEq)]
17pub enum TimelineRow {
18 Step(StepEntry),
20 Terminal(TerminalSegment),
22}
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct StepEntry {
27 pub step: i64,
30 pub ts_ms: i64,
32 pub kind: EventKind,
35 pub summary: String,
37 pub event_ids: Vec<i64>,
40}
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct TerminalSegment {
45 pub start_ts_ms: i64,
47 pub end_ts_ms: i64,
49 pub event_ids: Vec<i64>,
51}
52
53impl TimelineRow {
54 #[must_use]
56 pub fn ts_ms(&self) -> i64 {
57 match self {
58 TimelineRow::Step(s) => s.ts_ms,
59 TimelineRow::Terminal(t) => t.start_ts_ms,
60 }
61 }
62
63 #[must_use]
65 pub fn event_ids(&self) -> &[i64] {
66 match self {
67 TimelineRow::Step(s) => &s.event_ids,
68 TimelineRow::Terminal(t) => &t.event_ids,
69 }
70 }
71
72 #[must_use]
74 pub fn kind(&self) -> EventKind {
75 match self {
76 TimelineRow::Step(s) => s.kind,
77 TimelineRow::Terminal(_) => EventKind::TerminalOutput,
78 }
79 }
80
81 #[must_use]
84 pub fn label(&self) -> String {
85 match self {
86 TimelineRow::Step(s) => s.summary.clone(),
87 TimelineRow::Terminal(t) => {
88 let n = t.event_ids.len();
89 if n == 1 {
90 "1 terminal chunk".to_string()
91 } else {
92 format!("{n} terminal chunks")
93 }
94 }
95 }
96 }
97}
98
99#[must_use]
106pub fn build_timeline(events: &[EventIndexRow], show_terminal: bool) -> Vec<TimelineRow> {
107 let mut rows: Vec<TimelineRow> = Vec::new();
114 let mut step_index: HashMap<i64, usize> = HashMap::new();
115
116 let mut ordered: Vec<&EventIndexRow> = events.iter().collect();
120 ordered.sort_by_key(|e| (e.ts_ms, e.id));
121
122 for e in ordered {
123 if e.kind == EventKind::TerminalOutput {
124 if show_terminal {
125 push_terminal(&mut rows, e);
126 }
127 continue;
128 }
129 let Some(step) = e.step else {
130 rows.push(TimelineRow::Step(StepEntry {
135 step: 0,
136 ts_ms: e.ts_ms,
137 kind: e.kind,
138 summary: e.summary.clone(),
139 event_ids: vec![e.id],
140 }));
141 continue;
142 };
143 if let Some(&idx) = step_index.get(&step) {
144 if let TimelineRow::Step(entry) = &mut rows[idx] {
145 entry.event_ids.push(e.id);
146 entry.event_ids.sort_unstable();
147 entry.ts_ms = entry.ts_ms.min(e.ts_ms);
148 if badge_rank(e.kind) < badge_rank(entry.kind) {
152 entry.kind = e.kind;
153 entry.summary.clone_from(&e.summary);
154 }
155 }
156 continue;
157 }
158 step_index.insert(step, rows.len());
159 rows.push(TimelineRow::Step(StepEntry {
160 step,
161 ts_ms: e.ts_ms,
162 kind: e.kind,
163 summary: e.summary.clone(),
164 event_ids: vec![e.id],
165 }));
166 }
167
168 rows
169}
170
171fn badge_rank(kind: EventKind) -> u8 {
176 match kind {
177 EventKind::ToolResult | EventKind::McpResponse => 1,
178 _ => 0,
179 }
180}
181
182fn push_terminal(rows: &mut Vec<TimelineRow>, e: &EventIndexRow) {
187 if let Some(TimelineRow::Terminal(seg)) = rows.last_mut() {
188 seg.end_ts_ms = e.ts_ms;
189 seg.event_ids.push(e.id);
190 return;
191 }
192 rows.push(TimelineRow::Terminal(TerminalSegment {
193 start_ts_ms: e.ts_ms,
194 end_ts_ms: e.ts_ms,
195 event_ids: vec![e.id],
196 }));
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn row(
204 id: i64,
205 ts_ms: i64,
206 kind: EventKind,
207 step: Option<i64>,
208 correlates: Option<i64>,
209 ) -> EventIndexRow {
210 EventIndexRow {
211 id,
212 ts_ms,
213 kind,
214 step,
215 correlates,
216 summary: format!("{kind}#{id}"),
217 }
218 }
219
220 fn ids(rows: &[TimelineRow]) -> Vec<Vec<i64>> {
221 rows.iter().map(|r| r.event_ids().to_vec()).collect()
222 }
223
224 #[test]
225 fn groups_call_and_result_into_one_row() {
226 let events = vec![
227 row(1, 0, EventKind::ToolCall, Some(1), None),
228 row(2, 5, EventKind::ToolResult, Some(1), Some(1)),
229 ];
230 let rows = build_timeline(&events, false);
231 assert_eq!(rows.len(), 1);
232 let TimelineRow::Step(s) = &rows[0] else {
233 panic!("expected a step row")
234 };
235 assert_eq!(s.event_ids, vec![1, 2]);
236 assert_eq!(
237 s.kind,
238 EventKind::ToolCall,
239 "call is the primary badge kind"
240 );
241 assert_eq!(s.ts_ms, 0);
242 }
243
244 #[test]
245 fn out_of_order_result_still_shows_call_as_primary() {
246 let events = vec![
250 row(2, 0, EventKind::ToolResult, Some(1), Some(1)),
251 row(1, 10, EventKind::ToolCall, Some(1), None),
252 ];
253 let rows = build_timeline(&events, false);
254 assert_eq!(rows.len(), 1);
255 let TimelineRow::Step(s) = &rows[0] else {
256 panic!("expected a step row")
257 };
258 assert_eq!(
259 s.kind,
260 EventKind::ToolCall,
261 "call must win the badge, not whichever sorts first"
262 );
263 assert_eq!(s.ts_ms, 0, "row ts is still the earliest member");
264 assert_eq!(s.event_ids, vec![1, 2]);
265 }
266
267 #[test]
268 fn mcp_request_response_pair_prefers_request_badge() {
269 let events = vec![
270 row(1, 0, EventKind::McpRequest, Some(1), None),
271 row(2, 8, EventKind::McpResponse, Some(1), Some(1)),
272 ];
273 let rows = build_timeline(&events, false);
274 let TimelineRow::Step(s) = &rows[0] else {
275 panic!("expected a step row")
276 };
277 assert_eq!(s.kind, EventKind::McpRequest);
278 }
279
280 #[test]
281 fn terminal_hidden_by_default() {
282 let events = vec![
283 row(1, 0, EventKind::UserMessage, Some(1), None),
284 row(2, 5, EventKind::TerminalOutput, None, None),
285 row(3, 10, EventKind::AgentMessage, Some(2), None),
286 ];
287 let rows = build_timeline(&events, false);
288 assert_eq!(
289 rows.len(),
290 2,
291 "terminal_output must be excluded when show_terminal=false"
292 );
293 assert!(rows.iter().all(|r| !matches!(r, TimelineRow::Terminal(_))));
294 }
295
296 #[test]
297 fn terminal_shown_and_collapsed_into_one_segment() {
298 let events = vec![
299 row(1, 0, EventKind::UserMessage, Some(1), None),
300 row(2, 5, EventKind::TerminalOutput, None, None),
301 row(3, 6, EventKind::TerminalOutput, None, None),
302 row(4, 7, EventKind::TerminalOutput, None, None),
303 row(5, 10, EventKind::AgentMessage, Some(2), None),
304 ];
305 let rows = build_timeline(&events, true);
306 assert_eq!(
307 rows.len(),
308 3,
309 "3 consecutive terminal chunks collapse to 1 segment row"
310 );
311 let TimelineRow::Terminal(seg) = &rows[1] else {
312 panic!("expected a terminal segment in the middle")
313 };
314 assert_eq!(seg.event_ids, vec![2, 3, 4]);
315 assert_eq!(seg.start_ts_ms, 5);
316 assert_eq!(seg.end_ts_ms, 7);
317 }
318
319 #[test]
320 fn terminal_runs_split_by_an_intervening_step() {
321 let events = vec![
322 row(1, 0, EventKind::TerminalOutput, None, None),
323 row(2, 1, EventKind::TerminalOutput, None, None),
324 row(3, 2, EventKind::UserMessage, Some(1), None),
325 row(4, 3, EventKind::TerminalOutput, None, None),
326 ];
327 let rows = build_timeline(&events, true);
328 assert_eq!(ids(&rows), vec![vec![1, 2], vec![3], vec![4]]);
329 }
330
331 #[test]
332 fn rows_are_ts_ordered() {
333 let events = vec![
334 row(1, 20, EventKind::AgentMessage, Some(2), None),
335 row(2, 0, EventKind::UserMessage, Some(1), None),
336 ];
337 let rows = build_timeline(&events, false);
338 assert_eq!(
339 rows.iter().map(TimelineRow::ts_ms).collect::<Vec<_>>(),
340 vec![0, 20]
341 );
342 }
343
344 #[test]
345 fn no_step_event_gets_its_own_defensive_row() {
346 let events = vec![row(1, 0, EventKind::AgentMessage, None, None)];
347 let rows = build_timeline(&events, false);
348 assert_eq!(rows.len(), 1);
349 assert_eq!(rows[0].event_ids(), &[1]);
350 }
351}