Skip to main content

hh_core/
timeline.rs

1//! Pure timeline-grouping for replay/inspect (FR-3.2/FR-3.4).
2//!
3//! Turns a session's flat event index into display rows: one row per step
4//! (folding a correlated `tool_call`/`tool_result` or `mcp_request`/
5//! `mcp_response` pair into a single row), plus, when terminal segments are
6//! shown, runs of consecutive `terminal_output` events collapsed into one
7//! segment row. Pure and I/O-free — it operates on [`EventIndexRow`]s already
8//! loaded by [`crate::store::Store::list_event_index`], so it is unit
9//! testable without a database or terminal.
10
11use crate::event::{EventIndexRow, EventKind};
12use std::collections::HashMap;
13
14/// One row in the rendered timeline: either a semantic step or a collapsed
15/// run of terminal output.
16#[derive(Debug, Clone, PartialEq)]
17pub enum TimelineRow {
18    /// A semantic step (FR-3.4): one or more correlated events sharing a step.
19    Step(StepEntry),
20    /// A collapsed run of consecutive `terminal_output` events.
21    Terminal(TerminalSegment),
22}
23
24/// A semantic step row for the timeline pane.
25#[derive(Debug, Clone, PartialEq)]
26pub struct StepEntry {
27    /// 1-based step ordinal (0 for the defensive no-step fallback; see
28    /// [`build_timeline`]).
29    pub step: i64,
30    /// Earliest timestamp among the step's events (ms since session start).
31    pub ts_ms: i64,
32    /// The badge kind shown for this row (FR-3.2): the call/request side of a
33    /// correlated pair takes priority over its result/response.
34    pub kind: EventKind,
35    /// One-line summary of the primary (call/request-side) event.
36    pub summary: String,
37    /// Every event id sharing this step, ascending, chronological by id
38    /// (usually one; two for a correlated call+result / request+response pair).
39    pub event_ids: Vec<i64>,
40}
41
42/// A collapsed run of `terminal_output` events.
43#[derive(Debug, Clone, PartialEq)]
44pub struct TerminalSegment {
45    /// Timestamp of the first event in the run.
46    pub start_ts_ms: i64,
47    /// Timestamp of the last event in the run.
48    pub end_ts_ms: i64,
49    /// The `terminal_output` event ids in this run, chronological.
50    pub event_ids: Vec<i64>,
51}
52
53impl TimelineRow {
54    /// The timestamp used to order/locate this row (its earliest event's ts).
55    #[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    /// The event ids backing this row.
64    #[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    /// The badge kind for this row (`terminal_output` reuses its own kind).
73    #[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    /// A one-line label for this row (the step's summary, or a terminal
82    /// segment's synthetic "N lines" label).
83    #[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/// Build the timeline rows from a session's full event index (FR-3.2/FR-3.4).
100///
101/// `show_terminal` toggles whether `terminal_output` runs appear as
102/// [`TimelineRow::Terminal`] rows interleaved by timestamp (the `t` key in the
103/// replay TUI); when `false` they are omitted entirely and only step rows
104/// remain. Rows are always returned in ascending timestamp order.
105#[must_use]
106pub fn build_timeline(events: &[EventIndexRow], show_terminal: bool) -> Vec<TimelineRow> {
107    // Single forward pass, appending rows in true encounter order (so a
108    // terminal run is only merged with the immediately preceding row when
109    // that row is itself an adjacent terminal segment — an intervening step
110    // row correctly breaks the run). A step's row is created on its first
111    // member and updated in place when a later-arriving correlated member
112    // (e.g. a deferred tool_result) joins the same step.
113    let mut rows: Vec<TimelineRow> = Vec::new();
114    let mut step_index: HashMap<i64, usize> = HashMap::new();
115
116    // Sort a local index rather than assume the caller's order: this mirrors
117    // `step::assign_steps`'s own defensive sort and is what makes the
118    // terminal-run adjacency check below (rows.last_mut()) correct.
119    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            // Defensive fallback: a semantic event with no step assigned
131            // should not occur post-heal (ADR-0002 self-heals this on
132            // Store::open), but render it as its own row rather than
133            // dropping it silently.
134            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                // A call/request joining after its result/response was seen
149                // first (concurrent source; see step::assign_steps docs)
150                // takes over as the row's badge/summary primary.
151                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
171/// Badge priority within a correlated group: the "opening" side of a pair
172/// (call/request) is the row's primary kind/summary, never its result/
173/// response, regardless of which sorts first chronologically (a concurrent
174/// source can emit a result before its call — see `step::assign_steps` docs).
175fn badge_rank(kind: EventKind) -> u8 {
176    match kind {
177        EventKind::ToolResult | EventKind::McpResponse => 1,
178        _ => 0,
179    }
180}
181
182/// Append `e` to the last row's segment if it is a `Terminal` row (extending
183/// the run), otherwise start a new segment. Called only for
184/// `terminal_output` events in iteration (ts_ms, id) order, so consecutive
185/// calls with no intervening step row extend one run.
186fn 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        // Concurrent source: result (id=2) sorts before its call (id=1) by
247        // ts_ms but both already carry step=1 (assigned by the store's step
248        // pass before list_event_index is ever called).
249        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}