Skip to main content

agx/
tui.rs

1use crate::timeline::{
2    SessionTotals, Step, StepKind, ToolStats, compute_session_totals, compute_tool_stats,
3    format_duration_ms, is_error_result, truncate,
4};
5use anyhow::Result;
6use arboard::Clipboard;
7use crossterm::event::{
8    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton,
9    MouseEventKind,
10};
11use crossterm::execute;
12use crossterm::terminal::{
13    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
14};
15use ratatui::Terminal;
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap};
21use std::collections::HashMap;
22use std::io;
23use std::time::Duration;
24
25const PAGE_STEP: usize = 10;
26const HELP_POPUP_WIDTH: u16 = 64;
27const ALT_BG: Color = Color::Indexed(236);
28const SEARCH_HIT_BG: Color = Color::Indexed(58);
29
30enum InputMode {
31    Command(String),
32    Filter(String),
33    Search(String),
34    /// `a` in normal mode opens this mode for the current step. Enter
35    /// upserts the note (empty text deletes). Esc discards. The
36    /// attached `step_idx` is the *original* (pre-filter) index, so
37    /// the note lands on the right step even when the view is
38    /// filtered.
39    Annotation {
40        step_idx: usize,
41        buffer: String,
42    },
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum PendingKey {
47    SetMark,
48    JumpMark,
49}
50
51#[allow(clippy::struct_excessive_bools)]
52pub struct App {
53    steps: Vec<Step>,
54    list_state: ListState,
55    bg_flags: Vec<bool>,
56    batch_flags: Vec<bool>,
57    filtered_view: Vec<usize>,
58    filter: Option<String>,
59    search: Option<String>,
60    search_matches: Vec<usize>,
61    bookmarks: HashMap<char, usize>,
62    pending: Option<PendingKey>,
63    input_mode: Option<InputMode>,
64    show_help: bool,
65    status_msg: Option<String>,
66    list_area: Option<Rect>,
67    conversation_indices: Vec<usize>,
68    conversation_list_state: ListState,
69    three_pane: bool,
70    count_buffer: String,
71    tool_stats: Vec<ToolStats>,
72    heatmap: Vec<u8>,
73    show_heatmap: bool,
74    show_stats: bool,
75    /// When true, render the annotations list overlay. Toggled by `A`
76    /// in normal mode. `annotations_list_state` tracks the cursor inside
77    /// the overlay, independent of the main timeline's `list_state`.
78    show_annotations: bool,
79    annotations_list_state: ListState,
80    /// When true, render the conversation-branch (fork-root) list
81    /// overlay. Toggled by `b` in normal mode. Independent cursor.
82    /// Population is a pre-computed `fork_indices` so the overlay
83    /// stays constant-time to open.
84    show_forks: bool,
85    forks_list_state: ListState,
86    fork_indices: Vec<usize>,
87    /// When true, suppress cost estimates in status bar, detail pane, and
88    /// stats overlay. Token counts are still shown. Set via `--no-cost`.
89    no_cost: bool,
90    session_totals: SessionTotals,
91    /// Persisted per-step annotations. Empty by default when no session
92    /// path is bound (e.g. unit-test App construction). Mutations go
93    /// through `save_annotation` so on-disk state stays in sync with
94    /// the in-memory view.
95    annotations: crate::annotations::Annotations,
96    /// The session file we're attached to, used to derive the notes
97    /// file path. `None` in tests and for scratch usage where writes
98    /// would have no meaningful destination.
99    session_path: Option<std::path::PathBuf>,
100}
101
102impl App {
103    pub fn new(steps: Vec<Step>, no_cost: bool) -> Self {
104        let mut list_state = ListState::default();
105        if !steps.is_empty() {
106            list_state.select(Some(0));
107        }
108        let bg_flags = compute_bg_flags(&steps);
109        let batch_flags = compute_batch_flags(&steps);
110        let filtered_view: Vec<usize> = (0..steps.len()).collect();
111        let conversation_indices = compute_conversation_indices(&steps);
112        let mut conversation_list_state = ListState::default();
113        if !conversation_indices.is_empty() {
114            conversation_list_state.select(Some(0));
115        }
116        let mut app = Self {
117            steps,
118            list_state,
119            bg_flags,
120            batch_flags,
121            filtered_view,
122            filter: None,
123            search: None,
124            search_matches: Vec::new(),
125            bookmarks: HashMap::new(),
126            pending: None,
127            input_mode: None,
128            show_help: false,
129            status_msg: None,
130            list_area: None,
131            conversation_indices,
132            conversation_list_state,
133            three_pane: true,
134            count_buffer: String::new(),
135            tool_stats: Vec::new(),
136            heatmap: Vec::new(),
137            show_heatmap: false,
138            show_stats: false,
139            show_annotations: false,
140            annotations_list_state: ListState::default(),
141            show_forks: false,
142            forks_list_state: ListState::default(),
143            fork_indices: Vec::new(),
144            no_cost,
145            session_totals: SessionTotals::default(),
146            annotations: crate::annotations::Annotations::default(),
147            session_path: None,
148        };
149        app.tool_stats = compute_tool_stats(&app.steps);
150        app.heatmap = compute_heatmap(&app.steps);
151        app.session_totals = compute_session_totals(&app.steps);
152        app.fork_indices = crate::timeline::fork_root_indices(&app.steps);
153        app.sync_conversation_cursor();
154        app
155    }
156
157    fn toggle_heatmap(&mut self) {
158        self.show_heatmap = !self.show_heatmap;
159    }
160
161    fn reload_steps(&mut self, new_steps: Vec<Step>) {
162        let old_sel = self.list_state.selected();
163        self.steps = new_steps;
164        self.bg_flags = compute_bg_flags(&self.steps);
165        self.batch_flags = compute_batch_flags(&self.steps);
166        self.heatmap = compute_heatmap(&self.steps);
167        self.conversation_indices = compute_conversation_indices(&self.steps);
168        self.tool_stats = compute_tool_stats(&self.steps);
169        self.session_totals = compute_session_totals(&self.steps);
170        self.fork_indices = crate::timeline::fork_root_indices(&self.steps);
171        self.filter = None;
172        self.search = None;
173        self.search_matches.clear();
174        self.bookmarks.clear();
175        self.filtered_view = (0..self.steps.len()).collect();
176        if let Some(sel) = old_sel {
177            let clamped = sel.min(self.steps.len().saturating_sub(1));
178            self.list_state.select(Some(clamped));
179        }
180        self.sync_conversation_cursor();
181    }
182
183    fn toggle_stats(&mut self) {
184        self.show_stats = !self.show_stats;
185    }
186
187    /// Open or close the annotations list overlay. On open, seed the
188    /// overlay cursor at the first annotation so `Enter` always jumps
189    /// somewhere meaningful. When there are no annotations, open with
190    /// an empty `ListState` and render the empty-state hint.
191    fn toggle_annotations_list(&mut self) {
192        if self.show_annotations {
193            self.show_annotations = false;
194            return;
195        }
196        self.show_annotations = true;
197        let has_any = self.annotations.iter().next().is_some();
198        self.annotations_list_state
199            .select(if has_any { Some(0) } else { None });
200    }
201
202    /// Move the annotations-overlay cursor by `delta` (positive = down).
203    /// Clamped to the valid range; no-op when the list is empty.
204    fn annotations_cursor_move(&mut self, delta: isize) {
205        let len = self.annotations.iter().count();
206        if len == 0 {
207            return;
208        }
209        let cur = self.annotations_list_state.selected().unwrap_or(0);
210        let next = (cur as isize + delta).clamp(0, (len - 1) as isize);
211        self.annotations_list_state.select(Some(next as usize));
212    }
213
214    /// Open or close the conversation-branch (fork-root) list overlay.
215    /// Fork roots live outside the main linear timeline — edit/resume
216    /// in Claude Code creates them. The overlay is how users see the
217    /// branch structure without having to guess from the `║` gutter
218    /// marker in the main list.
219    fn toggle_forks_list(&mut self) {
220        if self.show_forks {
221            self.show_forks = false;
222            return;
223        }
224        self.show_forks = true;
225        self.forks_list_state
226            .select(if self.fork_indices.is_empty() {
227                None
228            } else {
229                Some(0)
230            });
231    }
232
233    /// Move the fork-overlay cursor by `delta`. Clamped; no-op when the
234    /// fork list is empty.
235    fn forks_cursor_move(&mut self, delta: isize) {
236        if self.fork_indices.is_empty() {
237            return;
238        }
239        let len = self.fork_indices.len();
240        let cur = self.forks_list_state.selected().unwrap_or(0);
241        let next = (cur as isize + delta).clamp(0, (len - 1) as isize);
242        self.forks_list_state.select(Some(next as usize));
243    }
244
245    /// Jump the main timeline cursor to the fork-root step selected in
246    /// the overlay. Same degradation story as the annotation overlay:
247    /// filter-hidden targets surface a status message rather than
248    /// silently moving somewhere else.
249    fn jump_to_selected_fork(&mut self) {
250        let Some(overlay_idx) = self.forks_list_state.selected() else {
251            self.show_forks = false;
252            return;
253        };
254        let Some(&orig_idx) = self.fork_indices.get(overlay_idx) else {
255            self.show_forks = false;
256            return;
257        };
258        self.show_forks = false;
259        match self.filtered_view.iter().position(|&i| i == orig_idx) {
260            Some(view_idx) => {
261                self.list_state.select(Some(view_idx));
262            }
263            None => {
264                self.status_msg = Some(format!(
265                    "fork root at step {} is hidden by the active filter",
266                    orig_idx + 1
267                ));
268            }
269        }
270    }
271
272    /// Jump the main timeline cursor to the annotation currently selected
273    /// in the overlay. Closes the overlay as a side effect so the user
274    /// lands on the step ready to act on it. When the target step is
275    /// hidden by the active filter, surface that via `status_msg`
276    /// instead of silently moving somewhere else.
277    fn jump_to_selected_annotation(&mut self) {
278        let Some(overlay_idx) = self.annotations_list_state.selected() else {
279            self.show_annotations = false;
280            return;
281        };
282        let Some((orig_idx, _)) = self.annotations.iter().nth(overlay_idx) else {
283            self.show_annotations = false;
284            return;
285        };
286        self.show_annotations = false;
287        match self.filtered_view.iter().position(|&i| i == orig_idx) {
288            Some(view_idx) => {
289                self.list_state.select(Some(view_idx));
290            }
291            None => {
292                self.status_msg = Some(format!(
293                    "annotation on step {} is hidden by the active filter",
294                    orig_idx + 1
295                ));
296            }
297        }
298    }
299
300    fn copy_current_step(&mut self) {
301        let Some(view_idx) = self.list_state.selected() else {
302            self.status_msg = Some("nothing to copy".into());
303            return;
304        };
305        let Some(&orig) = self.filtered_view.get(view_idx) else {
306            self.status_msg = Some("nothing to copy".into());
307            return;
308        };
309        let Some(step) = self.steps.get(orig) else {
310            self.status_msg = Some("nothing to copy".into());
311            return;
312        };
313        match Clipboard::new().and_then(|mut cb| cb.set_text(step.detail.clone())) {
314            Ok(()) => {
315                self.status_msg = Some(format!(
316                    "copied step {} to clipboard ({} chars)",
317                    orig + 1,
318                    step.detail.len()
319                ));
320            }
321            Err(e) => {
322                self.status_msg = Some(format!("clipboard error: {e}"));
323            }
324        }
325    }
326
327    fn append_count_digit(&mut self, c: char) {
328        if c.is_ascii_digit() && self.count_buffer.len() < 6 {
329            self.count_buffer.push(c);
330        }
331    }
332
333    fn take_count(&mut self) -> usize {
334        let n = self.count_buffer.parse::<usize>().unwrap_or(1).max(1);
335        self.count_buffer.clear();
336        n
337    }
338
339    fn has_count(&self) -> bool {
340        !self.count_buffer.is_empty()
341    }
342
343    fn clear_count(&mut self) {
344        self.count_buffer.clear();
345    }
346
347    fn sync_conversation_cursor(&mut self) {
348        let Some(view_idx) = self.list_state.selected() else {
349            self.conversation_list_state.select(None);
350            return;
351        };
352        let Some(&orig) = self.filtered_view.get(view_idx) else {
353            self.conversation_list_state.select(None);
354            return;
355        };
356        let target = self.conversation_indices.iter().rposition(|&i| i <= orig);
357        self.conversation_list_state.select(target);
358    }
359
360    fn toggle_layout(&mut self) {
361        self.three_pane = !self.three_pane;
362    }
363
364    fn click_to_select(&mut self, view_idx: usize) {
365        if self.filtered_view.is_empty() {
366            return;
367        }
368        let clamped = view_idx.min(self.filtered_view.len() - 1);
369        self.list_state.select(Some(clamped));
370    }
371
372    fn visible_count(&self) -> usize {
373        self.filtered_view.len()
374    }
375
376    fn next(&mut self) {
377        if self.filtered_view.is_empty() {
378            return;
379        }
380        let i = self.list_state.selected().unwrap_or(0);
381        let next = (i + 1).min(self.filtered_view.len() - 1);
382        self.list_state.select(Some(next));
383    }
384
385    fn prev(&mut self) {
386        if self.filtered_view.is_empty() {
387            return;
388        }
389        let i = self.list_state.selected().unwrap_or(0);
390        self.list_state.select(Some(i.saturating_sub(1)));
391    }
392
393    fn page_down(&mut self, n: usize) {
394        if self.filtered_view.is_empty() {
395            return;
396        }
397        let i = self.list_state.selected().unwrap_or(0);
398        let next = (i + n).min(self.filtered_view.len() - 1);
399        self.list_state.select(Some(next));
400    }
401
402    fn page_up(&mut self, n: usize) {
403        if self.filtered_view.is_empty() {
404            return;
405        }
406        let i = self.list_state.selected().unwrap_or(0);
407        self.list_state.select(Some(i.saturating_sub(n)));
408    }
409
410    fn home(&mut self) {
411        if !self.filtered_view.is_empty() {
412            self.list_state.select(Some(0));
413        }
414    }
415
416    fn end(&mut self) {
417        if !self.filtered_view.is_empty() {
418            self.list_state.select(Some(self.filtered_view.len() - 1));
419        }
420    }
421
422    fn toggle_help(&mut self) {
423        self.show_help = !self.show_help;
424    }
425
426    fn enter_command_mode(&mut self) {
427        self.input_mode = Some(InputMode::Command(String::new()));
428        self.status_msg = None;
429    }
430
431    fn enter_filter_mode(&mut self) {
432        let existing = self.filter.clone().unwrap_or_default();
433        self.input_mode = Some(InputMode::Filter(existing));
434        self.status_msg = None;
435    }
436
437    fn enter_search_mode(&mut self) {
438        let existing = self.search.clone().unwrap_or_default();
439        self.input_mode = Some(InputMode::Search(existing));
440        self.status_msg = None;
441    }
442
443    /// Open the annotation input for the current step. Prefills with
444    /// any existing note so `a` acts as edit-in-place when a note is
445    /// already there.
446    fn enter_annotation_mode(&mut self) {
447        let Some(view_idx) = self.list_state.selected() else {
448            self.status_msg = Some("no step selected to annotate".into());
449            return;
450        };
451        let Some(&orig_idx) = self.filtered_view.get(view_idx) else {
452            self.status_msg = Some("no step selected to annotate".into());
453            return;
454        };
455        let existing = self
456            .annotations
457            .get(orig_idx)
458            .map(|n| n.text.clone())
459            .unwrap_or_default();
460        self.input_mode = Some(InputMode::Annotation {
461            step_idx: orig_idx,
462            buffer: existing,
463        });
464        self.status_msg = None;
465    }
466
467    /// Apply an annotation buffer from input mode — upsert on non-empty
468    /// text, delete on empty. Save to disk when a session path is
469    /// bound; surface any write failure via `status_msg` rather than
470    /// panicking. Called from the Enter handler in the event loop.
471    fn save_annotation(&mut self, step_idx: usize, text: &str) {
472        let trimmed = text.trim();
473        let changed = self.annotations.set(step_idx, trimmed);
474        if let Some(path) = &self.session_path {
475            match self.annotations.save_for(path) {
476                Ok(_) => {
477                    let msg = if trimmed.is_empty() {
478                        format!("cleared annotation for step {}", step_idx + 1)
479                    } else if changed {
480                        format!("saved annotation for step {}", step_idx + 1)
481                    } else {
482                        "annotation unchanged".into()
483                    };
484                    self.status_msg = Some(msg);
485                }
486                Err(e) => {
487                    self.status_msg = Some(format!("annotation save failed: {e}"));
488                }
489            }
490        } else {
491            self.status_msg = Some("annotations not saved (no session path bound)".into());
492        }
493    }
494
495    fn begin_set_mark(&mut self) {
496        self.pending = Some(PendingKey::SetMark);
497        self.status_msg = None;
498    }
499
500    fn begin_jump_mark(&mut self) {
501        self.pending = Some(PendingKey::JumpMark);
502        self.status_msg = None;
503    }
504
505    fn cancel_pending(&mut self) {
506        self.pending = None;
507    }
508
509    fn set_mark(&mut self, ch: char) {
510        let Some(view_idx) = self.list_state.selected() else {
511            self.status_msg = Some("no current step to bookmark".into());
512            return;
513        };
514        let Some(&orig) = self.filtered_view.get(view_idx) else {
515            self.status_msg = Some("no current step to bookmark".into());
516            return;
517        };
518        self.bookmarks.insert(ch, orig);
519        self.status_msg = Some(format!("bookmark '{ch}' set at step {}", orig + 1));
520    }
521
522    fn jump_to_mark(&mut self, ch: char) {
523        let Some(&orig) = self.bookmarks.get(&ch) else {
524            self.status_msg = Some(format!("no bookmark '{ch}'"));
525            return;
526        };
527        match self.filtered_view.iter().position(|&i| i == orig) {
528            Some(view_idx) => {
529                self.list_state.select(Some(view_idx));
530            }
531            None => {
532                self.status_msg = Some(format!(
533                    "bookmark '{ch}' points to step {} (hidden by filter)",
534                    orig + 1
535                ));
536            }
537        }
538    }
539
540    /// Position the timeline cursor at a 0-indexed step, clamped to the
541    /// visible (filtered) range. Sets `status_msg` to a clamp warning
542    /// when the requested step is out of range. Extracted as a method
543    /// so the `--jump-to` CLI flag (sift Timeline-jump integration)
544    /// can be tested headlessly without stepping through the TUI event
545    /// loop.
546    pub(crate) fn apply_initial_step(&mut self, n: usize) {
547        if self.filtered_view.is_empty() {
548            return;
549        }
550        let max = self.filtered_view.len() - 1;
551        let target = n.min(max);
552        self.list_state.select(Some(target));
553        self.sync_conversation_cursor();
554        if n > max {
555            self.status_msg = Some(format!(
556                "--jump-to {n} out of range (session has {} steps); clamped to last",
557                self.filtered_view.len()
558            ));
559        }
560    }
561
562    fn goto_step(&mut self, step_num: usize) {
563        if self.filtered_view.is_empty() {
564            self.status_msg = Some("no steps to navigate".into());
565            return;
566        }
567        if step_num == 0 {
568            self.status_msg = Some("step number must be >= 1".into());
569            return;
570        }
571        let idx = (step_num - 1).min(self.filtered_view.len() - 1);
572        self.list_state.select(Some(idx));
573    }
574
575    fn execute_command(&mut self, input: &str) {
576        let trimmed = input.trim();
577        if trimmed.is_empty() {
578            return;
579        }
580        // `:@<duration>` — jump to first step at-or-after that offset
581        // from the session's first-step timestamp. Uses the shared
582        // slice duration parser so the grammar matches `--after` /
583        // `--before` exactly.
584        if let Some(offset_str) = trimmed.strip_prefix('@') {
585            match crate::slice::parse_duration_ms(offset_str) {
586                Ok(offset_ms) => self.goto_time_offset(offset_ms),
587                Err(e) => {
588                    self.status_msg = Some(format!("bad time offset `@{offset_str}`: {e}"));
589                }
590            }
591            return;
592        }
593        match trimmed.parse::<usize>() {
594            Ok(n) => self.goto_step(n),
595            Err(_) => {
596                self.status_msg = Some(format!("unknown command: :{trimmed}"));
597            }
598        }
599    }
600
601    /// Jump the cursor to the first step whose timestamp is at least
602    /// `offset_ms` past the session's first-step timestamp. Used by
603    /// the `:@<duration>` command. No-op (with a status message) when
604    /// the session has no step timestamps.
605    fn goto_time_offset(&mut self, offset_ms: u64) {
606        let Some(session_start_ms) = self.steps.iter().find_map(|s| s.timestamp_ms) else {
607            self.status_msg = Some("session has no step timestamps; `:@` jump unavailable".into());
608            return;
609        };
610        let target = session_start_ms.saturating_add(offset_ms);
611        let Some(target_idx) = self
612            .steps
613            .iter()
614            .position(|s| s.timestamp_ms.is_some_and(|ts| ts >= target))
615        else {
616            self.status_msg = Some(format!(
617                "no step at-or-after +{}ms from session start",
618                offset_ms
619            ));
620            return;
621        };
622        // Translate original-step index into the current filtered view.
623        // If the target is hidden by a filter, report it explicitly
624        // rather than silently jumping somewhere unexpected.
625        match self.filtered_view.iter().position(|&i| i == target_idx) {
626            Some(view_idx) => self.list_state.select(Some(view_idx)),
627            None => {
628                self.status_msg = Some(format!(
629                    "step {} at +{}ms is hidden by the active filter",
630                    target_idx + 1,
631                    offset_ms
632                ));
633            }
634        }
635    }
636
637    fn apply_filter(&mut self, query: &str) {
638        let trimmed = query.trim();
639        if trimmed.is_empty() {
640            self.clear_filter();
641            return;
642        }
643        let needle = trimmed.to_lowercase();
644        let indices: Vec<usize> = self
645            .steps
646            .iter()
647            .enumerate()
648            .filter(|(_, s)| s.label.to_lowercase().contains(&needle))
649            .map(|(i, _)| i)
650            .collect();
651        if indices.is_empty() {
652            self.status_msg = Some(format!("no matches for '{trimmed}'"));
653            return;
654        }
655        self.filter = Some(trimmed.to_string());
656        self.filtered_view = indices;
657        self.list_state.select(Some(0));
658        self.recompute_search_matches();
659    }
660
661    fn clear_filter(&mut self) {
662        self.filter = None;
663        self.filtered_view = (0..self.steps.len()).collect();
664        if self.filtered_view.is_empty() {
665            self.list_state.select(None);
666        } else {
667            self.list_state.select(Some(0));
668        }
669        self.recompute_search_matches();
670    }
671
672    fn apply_search(&mut self, query: &str) {
673        let trimmed = query.trim();
674        if trimmed.is_empty() {
675            self.clear_search();
676            return;
677        }
678        // Leading `//` → semantic search (Phase 4.4). Everything after the
679        // prefix is the query sent to the embedder. The `//` marker was
680        // picked over `/` because the regular search prompt is opened
681        // with `/`, and `//foo` is an unambiguous, easy-to-type way to
682        // say "I mean semantic, not substring." When the feature is off,
683        // `semantic::rank` returns `None` and we surface the rebuild
684        // hint via status_msg without touching current search state.
685        if let Some(sem_query) = trimmed.strip_prefix("//") {
686            self.apply_semantic_search(sem_query);
687            return;
688        }
689        self.search = Some(trimmed.to_string());
690        self.recompute_search_matches();
691        if self.search_matches.is_empty() {
692            self.status_msg = Some(format!("no matches for '{trimmed}'"));
693            self.search = None;
694            return;
695        }
696        // Jump to first match at-or-after the current selection, wrapping if needed.
697        let current = self.list_state.selected().unwrap_or(0);
698        let target = self
699            .search_matches
700            .iter()
701            .copied()
702            .find(|&idx| idx >= current)
703            .or_else(|| self.search_matches.first().copied());
704        if let Some(idx) = target {
705            self.list_state.select(Some(idx));
706        }
707    }
708
709    /// Dispatch a `//query` semantic search. Converts the embedder's
710    /// original-index results into filtered_view positions so the
711    /// existing highlight + jump paths work without modification.
712    /// When the feature is off, surfaces the rebuild hint and leaves
713    /// any active string-match search state untouched.
714    fn apply_semantic_search(&mut self, query: &str) {
715        let query = query.trim();
716        if query.is_empty() {
717            self.status_msg = Some("empty semantic query".into());
718            return;
719        }
720        let Some(orig_matches) = crate::semantic::rank(query, &self.steps) else {
721            self.status_msg = Some(crate::semantic::FEATURE_DISABLED_MESSAGE.into());
722            return;
723        };
724        if orig_matches.is_empty() {
725            self.status_msg = Some(format!("no semantic matches for '{query}'"));
726            self.search = None;
727            self.search_matches.clear();
728            return;
729        }
730        // Map original step indices into the current filtered view.
731        // Steps not in filtered_view are dropped (they can't be
732        // highlighted in the list anyway).
733        let mut view_matches: Vec<usize> = orig_matches
734            .into_iter()
735            .filter_map(|orig| self.filtered_view.iter().position(|&i| i == orig))
736            .collect();
737        view_matches.sort_unstable();
738        view_matches.dedup();
739        if view_matches.is_empty() {
740            self.status_msg = Some(format!(
741                "semantic matches for '{query}' are all hidden by the active filter"
742            ));
743            return;
744        }
745        self.search = Some(format!("//{query}"));
746        self.search_matches = view_matches;
747        // Jump to first match at-or-after the current selection.
748        let current = self.list_state.selected().unwrap_or(0);
749        let target = self
750            .search_matches
751            .iter()
752            .copied()
753            .find(|&idx| idx >= current)
754            .or_else(|| self.search_matches.first().copied());
755        if let Some(idx) = target {
756            self.list_state.select(Some(idx));
757        }
758    }
759
760    fn clear_search(&mut self) {
761        self.search = None;
762        self.search_matches.clear();
763    }
764
765    fn recompute_search_matches(&mut self) {
766        self.search_matches.clear();
767        let Some(query) = self.search.as_deref() else {
768            return;
769        };
770        // Semantic searches (stored with the `//` prefix in
771        // `self.search`) aren't re-embeddable on every filter change
772        // without blocking the UI. Drop them when filters change — the
773        // user can re-run `//query` to refresh. Cheaper than a cached
774        // embedding index and keeps the hot path reserved for
775        // substring search.
776        if query.starts_with("//") {
777            self.search = None;
778            return;
779        }
780        let needle = query.to_lowercase();
781        for (view_idx, &orig) in self.filtered_view.iter().enumerate() {
782            let Some(step) = self.steps.get(orig) else {
783                continue;
784            };
785            if step.label.to_lowercase().contains(&needle)
786                || step.detail.to_lowercase().contains(&needle)
787            {
788                self.search_matches.push(view_idx);
789            }
790        }
791    }
792
793    fn next_match(&mut self) {
794        if self.search_matches.is_empty() {
795            return;
796        }
797        let current = self.list_state.selected().unwrap_or(0);
798        let target = self
799            .search_matches
800            .iter()
801            .copied()
802            .find(|&idx| idx > current)
803            .or_else(|| self.search_matches.first().copied());
804        if let Some(idx) = target {
805            self.list_state.select(Some(idx));
806        }
807    }
808
809    fn prev_match(&mut self) {
810        if self.search_matches.is_empty() {
811            return;
812        }
813        let current = self.list_state.selected().unwrap_or(0);
814        let target = self
815            .search_matches
816            .iter()
817            .copied()
818            .rev()
819            .find(|&idx| idx < current)
820            .or_else(|| self.search_matches.last().copied());
821        if let Some(idx) = target {
822            self.list_state.select(Some(idx));
823        }
824    }
825}
826
827const HEATMAP_WINDOW: usize = 5;
828
829fn compute_heatmap(steps: &[Step]) -> Vec<u8> {
830    let len = steps.len();
831    (0..len)
832        .map(|i| {
833            let lo = i.saturating_sub(HEATMAP_WINDOW);
834            let hi = (i + HEATMAP_WINDOW + 1).min(len);
835            let count = steps[lo..hi]
836                .iter()
837                .filter(|s| matches!(s.kind, StepKind::ToolUse | StepKind::ToolResult))
838                .count();
839            u8::try_from(count).unwrap_or(u8::MAX)
840        })
841        .collect()
842}
843
844fn density_color(density: u8) -> Option<Color> {
845    match density {
846        0 => None,
847        1..=2 => Some(Color::Indexed(17)),
848        3..=4 => Some(Color::Indexed(22)),
849        5..=7 => Some(Color::Indexed(130)),
850        8..=10 => Some(Color::Indexed(208)),
851        _ => Some(Color::Indexed(196)),
852    }
853}
854
855// Detect runs of 2+ consecutive tool_use or tool_result steps. These are
856// batched parallel tool calls (Claude Code parallel Agent dispatches,
857// Codex batched function_calls). Returns a flag per original step index.
858fn compute_batch_flags(steps: &[Step]) -> Vec<bool> {
859    let mut flags = vec![false; steps.len()];
860    let mut i = 0;
861    while i < steps.len() {
862        let kind = steps[i].kind;
863        if matches!(kind, StepKind::ToolUse | StepKind::ToolResult) {
864            let mut j = i;
865            while j < steps.len() && steps[j].kind == kind {
866                j += 1;
867            }
868            if j - i >= 2 {
869                for item in flags.iter_mut().take(j).skip(i) {
870                    *item = true;
871                }
872            }
873            i = j;
874        } else {
875            i += 1;
876        }
877    }
878    flags
879}
880
881// Collect original step indices of user/assistant text steps, in order.
882// These form the "conversation view" — the flowing read-only pane that
883// complements the step-by-step timeline.
884fn compute_conversation_indices(steps: &[Step]) -> Vec<usize> {
885    steps
886        .iter()
887        .enumerate()
888        .filter_map(|(i, s)| {
889            matches!(s.kind, StepKind::UserText | StepKind::AssistantText).then_some(i)
890        })
891        .collect()
892}
893
894// Returns a Vec<bool> parallel to `steps`. For each ToolUse / ToolResult step,
895// the flag alternates so adjacent tool calls get distinct backgrounds. Text
896// steps get `false` (no alternating bg).
897fn compute_bg_flags(steps: &[Step]) -> Vec<bool> {
898    let mut flags = vec![false; steps.len()];
899    let mut tool_use_parity = false;
900    let mut tool_result_parity = false;
901    for (i, step) in steps.iter().enumerate() {
902        match step.kind {
903            StepKind::ToolUse => {
904                flags[i] = tool_use_parity;
905                tool_use_parity = !tool_use_parity;
906            }
907            StepKind::ToolResult => {
908                flags[i] = tool_result_parity;
909                tool_result_parity = !tool_result_parity;
910            }
911            _ => {}
912        }
913    }
914    flags
915}
916
917fn kind_color(kind: StepKind) -> Color {
918    // StepKind is `#[non_exhaustive]` per docs/stability.md — new
919    // variants (e.g. MCP resource reads from Phase 5.2) will fall
920    // through the wildcard and render in white until we add an
921    // explicit arm. That's the right default: visible but not
922    // mis-categorized.
923    match kind {
924        StepKind::UserText => Color::Cyan,
925        StepKind::AssistantText => Color::Green,
926        StepKind::ToolUse => Color::Yellow,
927        StepKind::ToolResult => Color::Magenta,
928        _ => Color::White,
929    }
930}
931
932fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
933    let width = width.min(area.width);
934    let height = height.min(area.height);
935    let x = area.x + (area.width.saturating_sub(width)) / 2;
936    let y = area.y + (area.height.saturating_sub(height)) / 2;
937    Rect {
938        x,
939        y,
940        width,
941        height,
942    }
943}
944
945struct TerminalGuard;
946
947impl Drop for TerminalGuard {
948    fn drop(&mut self) {
949        let _ = disable_raw_mode();
950        let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
951    }
952}
953
954/// Runtime configuration for live-mode desktop notifications. Kept
955/// here rather than in `notify.rs` because the field shape is a TUI
956/// concern — `notify.rs` owns only the "fire a notification" wrapper,
957/// not the policy for when to fire. Both fields are no-ops on a
958/// feature-off build (the `notify::error` / `notify::idle` calls
959/// return `Ok(())` without touching anything), so leaving this struct
960/// outside any `cfg` keeps the event loop readable.
961#[derive(Debug, Clone, Copy, Default)]
962pub struct NotifyConfig {
963    /// Fire a notification when a newly arrived `tool_result` matches
964    /// `is_error_result`.
965    pub on_error: bool,
966    /// Fire a notification when the session hasn't grown for at least
967    /// this many milliseconds. `None` disables. Fires at most once per
968    /// idle interval — growth resets the trigger.
969    pub on_idle_ms: Option<u64>,
970}
971
972pub fn run(
973    steps: Vec<Step>,
974    reload_fn: Option<&dyn Fn() -> Result<Vec<Step>>>,
975    no_cost: bool,
976    session_path: Option<&std::path::Path>,
977    initial_step: Option<usize>,
978    notify: NotifyConfig,
979    replay: crate::replay::ReplayConfig,
980) -> Result<()> {
981    enable_raw_mode()?;
982    execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
983    let _guard = TerminalGuard;
984
985    let backend = CrosstermBackend::new(io::stdout());
986    let mut terminal = Terminal::new(backend)?;
987    let mut app = App::new(steps, no_cost);
988    // Attach annotation state when a session path is provided. Load is
989    // fault-tolerant: a missing or malformed notes file returns an
990    // empty `Annotations` with a stderr warning, not an error.
991    if let Some(path) = session_path {
992        app.annotations = crate::annotations::Annotations::load_for(path);
993        app.session_path = Some(path.to_path_buf());
994    }
995    // Apply `--jump-to` if provided. 0-indexed, clamped to the visible
996    // range. Out-of-bounds surfaces a status-bar warning rather than
997    // exit-erroring — the TUI still launches so the user can see the
998    // session they asked for. Per docs/suite-conventions.md §5 this is
999    // the public CLI surface sift's Timeline-jump integration targets.
1000    if let Some(n) = initial_step {
1001        app.apply_initial_step(n);
1002    }
1003
1004    let result = run_loop(
1005        &mut terminal,
1006        &mut app,
1007        reload_fn,
1008        notify,
1009        replay,
1010        session_path,
1011    );
1012    let _ = terminal.show_cursor();
1013    result
1014}
1015
1016// run_loop is intentionally one function — TUI render + event handling form one
1017// logical operation per frame; splitting hurts readability more than it helps.
1018#[allow(clippy::too_many_lines)]
1019fn run_loop(
1020    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1021    app: &mut App,
1022    reload_fn: Option<&dyn Fn() -> Result<Vec<Step>>>,
1023    notify: NotifyConfig,
1024    replay: crate::replay::ReplayConfig,
1025    session_path: Option<&std::path::Path>,
1026) -> Result<()> {
1027    // Track the last time the session grew. Drives both --notify-on-idle
1028    // (fire when too-long-since-growth) and the idle_fired latch that
1029    // prevents the notification from re-firing every frame once the
1030    // threshold has been crossed.
1031    let mut last_growth = std::time::Instant::now();
1032    let mut idle_fired = false;
1033    // Phase 5.4 — pending replay confirm. Set when the user presses
1034    // `R` on a replayable step; cleared on y/n. A Some value means
1035    // "next keystroke is a confirm response."
1036    let mut pending_replay: Option<(usize, String)> = None;
1037    loop {
1038        // Live reload: poll file and refresh if step count changed.
1039        if let Some(reload) = reload_fn
1040            && let Ok(new_steps) = reload()
1041            && new_steps.len() != app.steps.len()
1042        {
1043            let prev_len = app.steps.len();
1044            let grew = new_steps.len() > prev_len;
1045            // Snapshot the newly-added steps before reload_steps() moves
1046            // the vec. For shrinkage (file truncation / rewrite) we
1047            // skip error scanning entirely — the delta isn't an append.
1048            let error_labels: Vec<String> = if grew && notify.on_error {
1049                new_steps[prev_len..]
1050                    .iter()
1051                    .filter(|s| is_error_result(s))
1052                    .map(|s| s.label.clone())
1053                    .collect()
1054            } else {
1055                Vec::new()
1056            };
1057            app.reload_steps(new_steps);
1058            last_growth = std::time::Instant::now();
1059            idle_fired = false;
1060            for label in error_labels {
1061                // Best-effort — a failed OS notification must never
1062                // crash the live loop.
1063                let _ = crate::notify::error(&label);
1064            }
1065        }
1066
1067        // Idle notification check runs every iteration (not just on
1068        // reload) so we fire promptly once the threshold elapses.
1069        if let Some(threshold_ms) = notify.on_idle_ms
1070            && !idle_fired
1071            && u64::try_from(last_growth.elapsed().as_millis()).unwrap_or(u64::MAX) >= threshold_ms
1072        {
1073            let _ = crate::notify::idle(threshold_ms / 1_000);
1074            idle_fired = true;
1075        }
1076
1077        terminal.draw(|f| {
1078            // Keep the conversation pane cursor in lockstep with the timeline.
1079            app.sync_conversation_cursor();
1080
1081            // Outer layout: main area + 1-row status/command bar at the bottom.
1082            let outer = Layout::default()
1083                .direction(Direction::Vertical)
1084                .constraints([Constraint::Min(1), Constraint::Length(1)])
1085                .split(f.area());
1086
1087            // Main area: either 3-pane (timeline | conversation | detail)
1088            // or 2-pane (timeline | detail). Tab toggles.
1089            let (chunks, conv_chunk): (std::rc::Rc<[Rect]>, Option<Rect>) = if app.three_pane {
1090                let split = Layout::default()
1091                    .direction(Direction::Horizontal)
1092                    .constraints([
1093                        Constraint::Percentage(25),
1094                        Constraint::Percentage(40),
1095                        Constraint::Percentage(35),
1096                    ])
1097                    .split(outer[0]);
1098                // Rearrange as [list, detail] so the existing downstream code
1099                // that renders list at chunks[0] and detail at chunks[1] still works.
1100                let repacked: std::rc::Rc<[Rect]> = std::rc::Rc::from(vec![split[0], split[2]]);
1101                (repacked, Some(split[1]))
1102            } else {
1103                (
1104                    Layout::default()
1105                        .direction(Direction::Horizontal)
1106                        .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
1107                        .split(outer[0]),
1108                    None,
1109                )
1110            };
1111            app.list_area = Some(chunks[0]);
1112
1113            let items: Vec<ListItem> = app
1114                .filtered_view
1115                .iter()
1116                .enumerate()
1117                .filter_map(|(view_idx, &orig_idx)| {
1118                    let s = app.steps.get(orig_idx)?;
1119                    let is_error = is_error_result(s);
1120                    let is_batched = app.batch_flags.get(orig_idx).copied().unwrap_or(false);
1121                    let color = if is_error {
1122                        Color::Red
1123                    } else {
1124                        kind_color(s.kind)
1125                    };
1126                    let mut style = Style::default().fg(color);
1127                    if is_error {
1128                        style = style.add_modifier(Modifier::BOLD);
1129                    }
1130                    let is_match = app.search_matches.binary_search(&view_idx).is_ok();
1131                    if is_match {
1132                        style = style.bg(SEARCH_HIT_BG).add_modifier(Modifier::BOLD);
1133                    } else if app.show_heatmap {
1134                        if let Some(color) =
1135                            app.heatmap.get(orig_idx).copied().and_then(density_color)
1136                        {
1137                            style = style.bg(color);
1138                        }
1139                    } else if app.bg_flags.get(orig_idx).copied().unwrap_or(false) {
1140                        style = style.bg(ALT_BG);
1141                    }
1142                    // Two-char prefix column. Annotations take priority
1143                    // over the batch marker — they're more load-bearing
1144                    // user signal (persistent, explicit) than the
1145                    // derived structural batch indicator.
1146                    let has_note = app.annotations.has(orig_idx);
1147                    let (prefix, prefix_style) = if has_note {
1148                        ("* ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
1149                    } else if is_batched {
1150                        ("║ ", Style::default().fg(Color::DarkGray))
1151                    } else {
1152                        ("  ", Style::default().fg(Color::DarkGray))
1153                    };
1154                    Some(ListItem::new(Line::from(vec![
1155                        Span::styled(prefix, prefix_style),
1156                        Span::styled(s.label.as_str(), style),
1157                    ])))
1158                })
1159                .collect();
1160
1161            let total = app.visible_count();
1162            let current = app.list_state.selected().map_or(0, |i| i + 1);
1163            let mut title_parts: Vec<String> = vec![format!(" agx — {current}/{total}")];
1164            if let Some(q) = &app.filter {
1165                title_parts.push(format!("[filter: {q}]"));
1166            }
1167            if let Some(q) = &app.search {
1168                let hits = app.search_matches.len();
1169                title_parts.push(format!("[search: {q} · {hits}]"));
1170            }
1171            // Fork-root count — shown only when >0 so linear sessions
1172            // (the common case) don't gain a noise segment.
1173            if !app.fork_indices.is_empty() {
1174                title_parts.push(format!("[forks: {} · b]", app.fork_indices.len()));
1175            }
1176            title_parts.push("[? help] ".to_string());
1177            let title = title_parts.join("   ");
1178
1179            let list = List::new(items)
1180                .block(Block::default().borders(Borders::ALL).title(title))
1181                .highlight_style(
1182                    Style::default()
1183                        .add_modifier(Modifier::REVERSED)
1184                        .add_modifier(Modifier::BOLD),
1185                );
1186
1187            f.render_stateful_widget(list, chunks[0], &mut app.list_state);
1188
1189            // Conversation pane (only in three-pane layout).
1190            if let Some(conv_area) = conv_chunk {
1191                let conv_items: Vec<ListItem> = app
1192                    .conversation_indices
1193                    .iter()
1194                    .filter_map(|&orig| {
1195                        let s = app.steps.get(orig)?;
1196                        let color = kind_color(s.kind);
1197                        let prefix = match s.kind {
1198                            StepKind::UserText => "user  ",
1199                            StepKind::AssistantText => "asst  ",
1200                            _ => "      ",
1201                        };
1202                        let mut text = String::with_capacity(prefix.len() + 200);
1203                        text.push_str(prefix);
1204                        for ch in s.detail.chars().take(200) {
1205                            text.push(if ch == '\n' { ' ' } else { ch });
1206                        }
1207                        Some(ListItem::new(Line::from(vec![Span::styled(
1208                            text,
1209                            Style::default().fg(color),
1210                        )])))
1211                    })
1212                    .collect();
1213                let conv_title = format!(" conversation ({}) ", app.conversation_indices.len());
1214                let conv_list = List::new(conv_items)
1215                    .block(Block::default().borders(Borders::ALL).title(conv_title))
1216                    .highlight_style(
1217                        Style::default()
1218                            .add_modifier(Modifier::REVERSED)
1219                            .add_modifier(Modifier::BOLD),
1220                    );
1221                f.render_stateful_widget(conv_list, conv_area, &mut app.conversation_list_state);
1222            }
1223
1224            let selected_orig = app
1225                .list_state
1226                .selected()
1227                .and_then(|i| app.filtered_view.get(i).copied());
1228            let (detail_text, detail_kind) = match selected_orig
1229                .and_then(|orig| app.steps.get(orig).map(|s| (orig, s)))
1230            {
1231                None => (String::new(), None),
1232                Some((orig, s)) => {
1233                    let mut text = s.detail.clone();
1234                    // Prepend a metadata block when this step carries
1235                    // duration / model / usage / annotation. Skipped
1236                    // entirely when none of those are known.
1237                    let mut meta: Vec<String> = Vec::new();
1238                    if let Some(note) = app.annotations.get(orig) {
1239                        meta.push(format!("[note: {}]", note.text));
1240                    }
1241                    if let Some(ms) = s.duration_ms {
1242                        meta.push(format!("[{} since previous step]", format_duration_ms(ms)));
1243                    }
1244                    if let Some(m) = &s.model {
1245                        meta.push(format!("[model: {m}]"));
1246                    }
1247                    let has_tokens = s.tokens_in.is_some()
1248                        || s.tokens_out.is_some()
1249                        || s.cache_read.is_some()
1250                        || s.cache_create.is_some();
1251                    if has_tokens {
1252                        let parts: Vec<String> = [
1253                            ("in", s.tokens_in),
1254                            ("out", s.tokens_out),
1255                            ("cache_read", s.cache_read),
1256                            ("cache_create", s.cache_create),
1257                        ]
1258                        .iter()
1259                        .filter_map(|(label, v)| v.map(|n| format!("{label}: {n}")))
1260                        .collect();
1261                        meta.push(format!("[tokens — {}]", parts.join(", ")));
1262                    }
1263                    if !app.no_cost
1264                        && let Some(c) = s.cost_usd()
1265                    {
1266                        meta.push(format!("[estimated cost: ${c:.4} USD]"));
1267                    }
1268                    if !meta.is_empty() {
1269                        text = format!("{}\n\n{}", meta.join("\n"), text);
1270                    }
1271                    (text, Some(s.kind))
1272                }
1273            };
1274
1275            let detail_title = match detail_kind {
1276                Some(StepKind::UserText) => " user ",
1277                Some(StepKind::AssistantText) => " assistant ",
1278                Some(StepKind::ToolUse) => " tool_use ",
1279                Some(StepKind::ToolResult) => " tool_result ",
1280                // Future `#[non_exhaustive]` variant — fall back to
1281                // the generic title until an explicit arm lands.
1282                Some(_) | None => " detail ",
1283            };
1284
1285            let detail_widget = Paragraph::new(detail_text)
1286                .block(
1287                    Block::default()
1288                        .borders(Borders::ALL)
1289                        .title(detail_title)
1290                        .border_style(
1291                            Style::default().fg(detail_kind.map_or(Color::Gray, kind_color)),
1292                        ),
1293                )
1294                .wrap(Wrap { trim: false });
1295
1296            f.render_widget(detail_widget, chunks[1]);
1297
1298            // Bottom bar: pending hint, input line, status msg, or scrubbing gauge.
1299            if let Some(pending) = app.pending {
1300                let hint = match pending {
1301                    PendingKey::SetMark => "set mark: press a-z to bookmark current step",
1302                    PendingKey::JumpMark => "jump to mark: press a-z to navigate",
1303                };
1304                let line = Paragraph::new(Line::from(vec![Span::styled(
1305                    hint,
1306                    Style::default()
1307                        .fg(Color::Yellow)
1308                        .add_modifier(Modifier::BOLD),
1309                )]));
1310                f.render_widget(line, outer[1]);
1311            } else {
1312                match &app.input_mode {
1313                    Some(InputMode::Command(buf)) => {
1314                        let line = Paragraph::new(Line::from(vec![
1315                            Span::styled(":", Style::default().fg(Color::Yellow)),
1316                            Span::raw(buf.as_str()),
1317                            Span::styled(
1318                                "█",
1319                                Style::default()
1320                                    .fg(Color::Yellow)
1321                                    .add_modifier(Modifier::SLOW_BLINK),
1322                            ),
1323                        ]));
1324                        f.render_widget(line, outer[1]);
1325                    }
1326                    Some(InputMode::Filter(buf)) => {
1327                        let line = Paragraph::new(Line::from(vec![
1328                            Span::styled(
1329                                "filter> ",
1330                                Style::default()
1331                                    .fg(Color::Cyan)
1332                                    .add_modifier(Modifier::BOLD),
1333                            ),
1334                            Span::raw(buf.as_str()),
1335                            Span::styled(
1336                                "█",
1337                                Style::default()
1338                                    .fg(Color::Cyan)
1339                                    .add_modifier(Modifier::SLOW_BLINK),
1340                            ),
1341                        ]));
1342                        f.render_widget(line, outer[1]);
1343                    }
1344                    Some(InputMode::Search(buf)) => {
1345                        let line = Paragraph::new(Line::from(vec![
1346                            Span::styled(
1347                                "/",
1348                                Style::default()
1349                                    .fg(Color::Yellow)
1350                                    .add_modifier(Modifier::BOLD),
1351                            ),
1352                            Span::raw(buf.as_str()),
1353                            Span::styled(
1354                                "█",
1355                                Style::default()
1356                                    .fg(Color::Yellow)
1357                                    .add_modifier(Modifier::SLOW_BLINK),
1358                            ),
1359                        ]));
1360                        f.render_widget(line, outer[1]);
1361                    }
1362                    Some(InputMode::Annotation { step_idx, buffer }) => {
1363                        let line = Paragraph::new(Line::from(vec![
1364                            Span::styled(
1365                                format!("note step {}> ", step_idx + 1),
1366                                Style::default()
1367                                    .fg(Color::Magenta)
1368                                    .add_modifier(Modifier::BOLD),
1369                            ),
1370                            Span::raw(buffer.as_str()),
1371                            Span::styled(
1372                                "█",
1373                                Style::default()
1374                                    .fg(Color::Magenta)
1375                                    .add_modifier(Modifier::SLOW_BLINK),
1376                            ),
1377                        ]));
1378                        f.render_widget(line, outer[1]);
1379                    }
1380                    None => {
1381                        if let Some(msg) = &app.status_msg {
1382                            let line = Paragraph::new(Line::from(vec![Span::styled(
1383                                msg.as_str(),
1384                                Style::default().fg(Color::Red),
1385                            )]));
1386                            f.render_widget(line, outer[1]);
1387                        } else {
1388                            let ratio = if total == 0 {
1389                                0.0
1390                            } else {
1391                                #[allow(clippy::cast_precision_loss)]
1392                                let r = current as f64 / total as f64;
1393                                r.clamp(0.0, 1.0)
1394                            };
1395                            let mut parts = vec![format!("{current}/{total}")];
1396                            if let Some(q) = &app.filter {
1397                                parts.push(format!("filter: {q}"));
1398                            }
1399                            if let Some(q) = &app.search {
1400                                parts.push(format!("search: {q} ({})", app.search_matches.len()));
1401                            }
1402                            if !app.count_buffer.is_empty() {
1403                                parts.push(format!("×{}", app.count_buffer));
1404                            }
1405                            if !app.no_cost
1406                                && let Some(c) = app.session_totals.cost_usd
1407                            {
1408                                parts.push(format!("cost: ${c:.4}"));
1409                            }
1410                            let label = parts.join("  ");
1411                            let gauge = Gauge::default()
1412                                .gauge_style(
1413                                    Style::default()
1414                                        .fg(Color::Cyan)
1415                                        .bg(Color::Reset)
1416                                        .add_modifier(Modifier::BOLD),
1417                                )
1418                                .ratio(ratio)
1419                                .label(label);
1420                            f.render_widget(gauge, outer[1]);
1421                        }
1422                    }
1423                }
1424            }
1425
1426            if app.show_help {
1427                let help_lines = vec![
1428                    Line::from(Span::styled(
1429                        "agx — step-through debugger for AI agent traces",
1430                        Style::default().add_modifier(Modifier::BOLD),
1431                    )),
1432                    Line::from(""),
1433                    Line::from(Span::styled(
1434                        "Navigation",
1435                        Style::default().add_modifier(Modifier::BOLD),
1436                    )),
1437                    Line::from("  ↓ / j           next step"),
1438                    Line::from("  ↑ / k           prev step"),
1439                    Line::from("  PgDn / d        jump 10 steps forward"),
1440                    Line::from("  PgUp / u        jump 10 steps back"),
1441                    Line::from("  Home / g        first step"),
1442                    Line::from("  End  / G        last step"),
1443                    Line::from("  :N              jump to visible row N"),
1444                    Line::from("  :@<duration>    jump to first step ≥ offset from session start (1h30m, 5m, 90s)"),
1445                    Line::from("  <N><motion>     vim count prefix (3j, 5k, 2d, 42G, ...)"),
1446                    Line::from(""),
1447                    Line::from(Span::styled(
1448                        "Filter",
1449                        Style::default().add_modifier(Modifier::BOLD),
1450                    )),
1451                    Line::from("  f               open filter prompt (hides non-matching rows)"),
1452                    Line::from("  (empty enter)   clear current filter"),
1453                    Line::from(""),
1454                    Line::from(Span::styled(
1455                        "Search",
1456                        Style::default().add_modifier(Modifier::BOLD),
1457                    )),
1458                    Line::from("  /               open search prompt (highlights matches)"),
1459                    Line::from("  //query         semantic search (opt-in; --features embedding-search)"),
1460                    Line::from("  n               next match"),
1461                    Line::from("  N               prev match"),
1462                    Line::from("  (empty enter)   clear current search"),
1463                    Line::from(""),
1464                    Line::from(Span::styled(
1465                        "Bookmarks",
1466                        Style::default().add_modifier(Modifier::BOLD),
1467                    )),
1468                    Line::from("  m<char>         set bookmark at current step"),
1469                    Line::from("  '<char>         jump to bookmark"),
1470                    Line::from(""),
1471                    Line::from(Span::styled(
1472                        "Other",
1473                        Style::default().add_modifier(Modifier::BOLD),
1474                    )),
1475                    Line::from("  a               add / edit / clear annotation on current step (saved to ~/.agx/notes/)"),
1476                    Line::from("  A               list all annotations (Enter jumps to step, Esc closes)"),
1477                    Line::from("  b               list all fork roots (Claude Code edit/resume branches)"),
1478                    Line::from("  y               copy current step to clipboard"),
1479                    Line::from("  h               toggle heatmap mode (tool-call density)"),
1480                    Line::from("  ? / F1          toggle this help"),
1481                    Line::from("  s               toggle tool usage stats overlay"),
1482                    Line::from("  Tab             toggle 3-pane / 2-pane layout"),
1483                    Line::from("  mouse click     select row in timeline"),
1484                    Line::from("  mouse scroll    prev / next step"),
1485                    Line::from("  q / Esc         quit"),
1486                    Line::from(""),
1487                    Line::from(Span::styled(
1488                        "Color legend",
1489                        Style::default().add_modifier(Modifier::BOLD),
1490                    )),
1491                    Line::from(vec![
1492                        Span::raw("  "),
1493                        Span::styled("cyan   ", Style::default().fg(Color::Cyan)),
1494                        Span::raw("user message"),
1495                    ]),
1496                    Line::from(vec![
1497                        Span::raw("  "),
1498                        Span::styled("green  ", Style::default().fg(Color::Green)),
1499                        Span::raw("assistant message"),
1500                    ]),
1501                    Line::from(vec![
1502                        Span::raw("  "),
1503                        Span::styled("yellow ", Style::default().fg(Color::Yellow)),
1504                        Span::raw("tool_use (alternating bg per call)"),
1505                    ]),
1506                    Line::from(vec![
1507                        Span::raw("  "),
1508                        Span::styled("magenta", Style::default().fg(Color::Magenta)),
1509                        Span::raw(" tool_result (alternating bg per result)"),
1510                    ]),
1511                    Line::from(vec![
1512                        Span::raw("  "),
1513                        Span::styled(
1514                            "red    ",
1515                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1516                        ),
1517                        Span::raw("error (failed tool call, heuristic)"),
1518                    ]),
1519                    Line::from(vec![
1520                        Span::raw("  "),
1521                        Span::styled("║      ", Style::default().fg(Color::DarkGray)),
1522                        Span::raw("part of a batched parallel tool call"),
1523                    ]),
1524                    Line::from(vec![
1525                        Span::raw("  "),
1526                        Span::styled(
1527                            "*      ",
1528                            Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
1529                        ),
1530                        Span::raw("has an annotation (see `a` keybinding)"),
1531                    ]),
1532                    Line::from(""),
1533                    Line::from(Span::styled(
1534                        "Press any key to dismiss",
1535                        Style::default().fg(Color::DarkGray),
1536                    )),
1537                ];
1538
1539                let help_height = u16::try_from(help_lines.len())
1540                    .unwrap_or(u16::MAX)
1541                    .saturating_add(2);
1542                let help_area = centered_rect(HELP_POPUP_WIDTH, help_height, f.area());
1543
1544                let help_widget = Paragraph::new(help_lines).block(
1545                    Block::default()
1546                        .borders(Borders::ALL)
1547                        .title(" help ")
1548                        .border_style(Style::default().fg(Color::White)),
1549                );
1550
1551                f.render_widget(Clear, help_area);
1552                f.render_widget(help_widget, help_area);
1553            }
1554
1555            if app.show_stats {
1556                let mut lines = vec![
1557                    Line::from(Span::styled(
1558                        "agx — tool usage statistics",
1559                        Style::default().add_modifier(Modifier::BOLD),
1560                    )),
1561                    Line::from(""),
1562                    Line::from(format!(
1563                        "Total steps: {}   Unique tools: {}",
1564                        app.steps.len(),
1565                        app.tool_stats.len()
1566                    )),
1567                ];
1568                if app.session_totals.has_tokens() {
1569                    lines.push(Line::from(format!(
1570                        "Tokens — in: {}, out: {}, cache_read: {}, cache_create: {}",
1571                        app.session_totals.tokens_in,
1572                        app.session_totals.tokens_out,
1573                        app.session_totals.cache_read,
1574                        app.session_totals.cache_create,
1575                    )));
1576                    if !app.session_totals.unique_models.is_empty() {
1577                        lines.push(Line::from(format!(
1578                            "Models: {}",
1579                            app.session_totals.unique_models.join(", ")
1580                        )));
1581                    }
1582                    if !app.no_cost {
1583                        match app.session_totals.cost_usd {
1584                            Some(c) => {
1585                                lines.push(Line::from(format!("Estimated cost: ${c:.4} USD")))
1586                            }
1587                            None => lines.push(Line::from(Span::styled(
1588                                "Estimated cost: (no pricing entry for model)",
1589                                Style::default().fg(Color::DarkGray),
1590                            ))),
1591                        }
1592                    }
1593                }
1594                lines.extend([
1595                    Line::from(""),
1596                    Line::from(Span::styled(
1597                        format!(
1598                            "{:<22} {:>6} {:>8} {:>8} {:>9}",
1599                            "Tool", "uses", "results", "errors", "err%"
1600                        ),
1601                        Style::default().add_modifier(Modifier::BOLD),
1602                    )),
1603                ]);
1604                for s in app.tool_stats.iter().take(18) {
1605                    let err_pct = match s.error_rate() {
1606                        Some(r) => format!("{:>7.1}%", r * 100.0),
1607                        None => "      -".to_string(),
1608                    };
1609                    let err_color = if s.error_count > 0 {
1610                        Color::Red
1611                    } else {
1612                        Color::White
1613                    };
1614                    lines.push(Line::from(vec![Span::styled(
1615                        format!(
1616                            "{:<22} {:>6} {:>8} {:>8} {:>9}",
1617                            truncate(&s.name, 22),
1618                            s.use_count,
1619                            s.result_count,
1620                            s.error_count,
1621                            err_pct
1622                        ),
1623                        Style::default().fg(err_color),
1624                    )]));
1625                }
1626                if app.tool_stats.len() > 18 {
1627                    lines.push(Line::from(Span::styled(
1628                        format!("... ({} more not shown)", app.tool_stats.len() - 18),
1629                        Style::default().fg(Color::DarkGray),
1630                    )));
1631                }
1632                lines.push(Line::from(""));
1633                lines.push(Line::from(Span::styled(
1634                    "Press any key to dismiss",
1635                    Style::default().fg(Color::DarkGray),
1636                )));
1637
1638                let height = u16::try_from(lines.len())
1639                    .unwrap_or(u16::MAX)
1640                    .saturating_add(2);
1641                let area = centered_rect(70, height, f.area());
1642                let widget = Paragraph::new(lines).block(
1643                    Block::default()
1644                        .borders(Borders::ALL)
1645                        .title(" stats ")
1646                        .border_style(Style::default().fg(Color::White)),
1647                );
1648                f.render_widget(Clear, area);
1649                f.render_widget(widget, area);
1650            }
1651
1652            if app.show_annotations {
1653                // Collect once into an owned vec so the ListState navigation
1654                // and the row rendering see identical ordering.
1655                let notes: Vec<(usize, String, String)> = app
1656                    .annotations
1657                    .iter()
1658                    .map(|(idx, note)| {
1659                        let label = app
1660                            .steps
1661                            .get(idx)
1662                            .map(|s| s.label.clone())
1663                            .unwrap_or_else(|| "(missing step)".into());
1664                        (idx, label, note.text.clone())
1665                    })
1666                    .collect();
1667                let count = notes.len();
1668                let items: Vec<ListItem> = if count == 0 {
1669                    vec![ListItem::new(Line::from(Span::styled(
1670                        "  (no annotations — press `a` on a step to add one)",
1671                        Style::default().fg(Color::DarkGray),
1672                    )))]
1673                } else {
1674                    notes
1675                        .iter()
1676                        .map(|(idx, label, text)| {
1677                            let preview = truncate(text, 60);
1678                            ListItem::new(vec![
1679                                Line::from(vec![
1680                                    Span::styled(
1681                                        format!("step {:>4}  ", idx + 1),
1682                                        Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
1683                                    ),
1684                                    Span::styled(
1685                                        truncate(label, 50),
1686                                        Style::default().fg(Color::White),
1687                                    ),
1688                                ]),
1689                                Line::from(Span::styled(
1690                                    format!("           {preview}"),
1691                                    Style::default().fg(Color::Gray),
1692                                )),
1693                            ])
1694                        })
1695                        .collect()
1696                };
1697                let title = format!(" annotations ({count}) — Enter jumps · Esc closes ");
1698                // 4 lines of chrome (border top + border bottom + header + footer hint) +
1699                // 2 lines per note (label + preview); 1 line when empty.
1700                let body_lines = if count == 0 { 1 } else { count * 2 };
1701                let height = u16::try_from(body_lines + 4)
1702                    .unwrap_or(u16::MAX)
1703                    .clamp(6, 20);
1704                let area = centered_rect(80, height, f.area());
1705                let list = List::new(items)
1706                    .block(
1707                        Block::default()
1708                            .borders(Borders::ALL)
1709                            .title(title)
1710                            .border_style(Style::default().fg(Color::Magenta)),
1711                    )
1712                    .highlight_style(
1713                        Style::default()
1714                            .add_modifier(Modifier::REVERSED)
1715                            .add_modifier(Modifier::BOLD),
1716                    );
1717                f.render_widget(Clear, area);
1718                f.render_stateful_widget(list, area, &mut app.annotations_list_state);
1719            }
1720
1721            if app.show_forks {
1722                let count = app.fork_indices.len();
1723                let items: Vec<ListItem> = if count == 0 {
1724                    vec![ListItem::new(Line::from(Span::styled(
1725                        "  (no forks — this session is linear)",
1726                        Style::default().fg(Color::DarkGray),
1727                    )))]
1728                } else {
1729                    app.fork_indices
1730                        .iter()
1731                        .map(|&orig_idx| {
1732                            let label = app
1733                                .steps
1734                                .get(orig_idx)
1735                                .map(|s| truncate(&s.label, 60))
1736                                .unwrap_or_else(|| "(missing step)".into());
1737                            ListItem::new(Line::from(vec![
1738                                Span::styled(
1739                                    format!("step {:>4}  ", orig_idx + 1),
1740                                    Style::default()
1741                                        .fg(Color::DarkGray)
1742                                        .add_modifier(Modifier::BOLD),
1743                                ),
1744                                Span::styled(label, Style::default().fg(Color::White)),
1745                            ]))
1746                        })
1747                        .collect()
1748                };
1749                let title = format!(" forks ({count}) — Enter jumps · Esc closes ");
1750                // 4 lines of chrome + 1 line per fork; 1 line when empty.
1751                let body_lines = if count == 0 { 1 } else { count };
1752                let height = u16::try_from(body_lines + 4)
1753                    .unwrap_or(u16::MAX)
1754                    .clamp(6, 20);
1755                let area = centered_rect(78, height, f.area());
1756                let list = List::new(items)
1757                    .block(
1758                        Block::default()
1759                            .borders(Borders::ALL)
1760                            .title(title)
1761                            .border_style(Style::default().fg(Color::DarkGray)),
1762                    )
1763                    .highlight_style(
1764                        Style::default()
1765                            .add_modifier(Modifier::REVERSED)
1766                            .add_modifier(Modifier::BOLD),
1767                    );
1768                f.render_widget(Clear, area);
1769                f.render_stateful_widget(list, area, &mut app.forks_list_state);
1770            }
1771        })?;
1772
1773        let poll_timeout = if reload_fn.is_some() {
1774            Duration::from_millis(500)
1775        } else {
1776            Duration::from_secs(60)
1777        };
1778        if !event::poll(poll_timeout)? {
1779            continue;
1780        }
1781        let ev = event::read()?;
1782        if let Event::Mouse(mouse) = ev {
1783            match mouse.kind {
1784                MouseEventKind::ScrollUp => app.prev(),
1785                MouseEventKind::ScrollDown => app.next(),
1786                MouseEventKind::Down(MouseButton::Left) => {
1787                    if let Some(area) = app.list_area
1788                        && mouse.row > area.y
1789                        && mouse.row < area.y + area.height - 1
1790                        && mouse.column > area.x
1791                        && mouse.column < area.x + area.width - 1
1792                    {
1793                        // Inside the list, excluding the border.
1794                        let row_within_list = usize::from(mouse.row - area.y - 1);
1795                        let view_idx = app.list_state.offset() + row_within_list;
1796                        app.click_to_select(view_idx);
1797                    }
1798                }
1799                _ => {}
1800            }
1801            continue;
1802        }
1803        if let Event::Key(key) = ev
1804            && key.kind == KeyEventKind::Press
1805        {
1806            // Help overlay: any key dismisses.
1807            if app.show_help {
1808                app.show_help = false;
1809                continue;
1810            }
1811
1812            // Stats overlay: any key dismisses.
1813            if app.show_stats {
1814                app.show_stats = false;
1815                continue;
1816            }
1817
1818            // Annotations overlay: navigable — j/k/arrows move, Enter
1819            // jumps to the selected note's step, any other key closes.
1820            if app.show_annotations {
1821                match key.code {
1822                    KeyCode::Down | KeyCode::Char('j') => app.annotations_cursor_move(1),
1823                    KeyCode::Up | KeyCode::Char('k') => app.annotations_cursor_move(-1),
1824                    KeyCode::Enter => app.jump_to_selected_annotation(),
1825                    _ => app.show_annotations = false,
1826                }
1827                continue;
1828            }
1829
1830            // Fork-list overlay: same nav contract as annotations.
1831            if app.show_forks {
1832                match key.code {
1833                    KeyCode::Down | KeyCode::Char('j') => app.forks_cursor_move(1),
1834                    KeyCode::Up | KeyCode::Char('k') => app.forks_cursor_move(-1),
1835                    KeyCode::Enter => app.jump_to_selected_fork(),
1836                    _ => app.show_forks = false,
1837                }
1838                continue;
1839            }
1840
1841            // Pending bookmark key (m<char> or '<char>): consume the next event.
1842            if let Some(pending) = app.pending {
1843                if let KeyCode::Char(c) = key.code {
1844                    match pending {
1845                        PendingKey::SetMark => app.set_mark(c),
1846                        PendingKey::JumpMark => app.jump_to_mark(c),
1847                    }
1848                }
1849                app.cancel_pending();
1850                continue;
1851            }
1852
1853            // Input mode (command / filter / search): its own keybinding scope.
1854            if app.input_mode.is_some() {
1855                match key.code {
1856                    KeyCode::Esc => {
1857                        app.input_mode = None;
1858                        app.status_msg = None;
1859                    }
1860                    KeyCode::Enter => {
1861                        let mode = app.input_mode.take();
1862                        match mode {
1863                            Some(InputMode::Command(buf)) => app.execute_command(&buf),
1864                            Some(InputMode::Filter(buf)) => app.apply_filter(&buf),
1865                            Some(InputMode::Search(buf)) => app.apply_search(&buf),
1866                            Some(InputMode::Annotation { step_idx, buffer }) => {
1867                                app.save_annotation(step_idx, &buffer);
1868                            }
1869                            None => {}
1870                        }
1871                    }
1872                    KeyCode::Backspace => {
1873                        if let Some(
1874                            InputMode::Command(buf)
1875                            | InputMode::Filter(buf)
1876                            | InputMode::Search(buf)
1877                            | InputMode::Annotation { buffer: buf, .. },
1878                        ) = &mut app.input_mode
1879                        {
1880                            buf.pop();
1881                        }
1882                    }
1883                    KeyCode::Char(c) => {
1884                        if let Some(
1885                            InputMode::Command(buf)
1886                            | InputMode::Filter(buf)
1887                            | InputMode::Search(buf)
1888                            | InputMode::Annotation { buffer: buf, .. },
1889                        ) = &mut app.input_mode
1890                        {
1891                            buf.push(c);
1892                        }
1893                    }
1894                    _ => {}
1895                }
1896                continue;
1897            }
1898
1899            // Normal mode.
1900            app.status_msg = None;
1901
1902            // Vim-style count prefix: digits 1-9 always start a count; 0 joins
1903            // an existing count (otherwise it's just an unbound key).
1904            if let KeyCode::Char(c @ '0'..='9') = key.code
1905                && (c != '0' || app.has_count())
1906            {
1907                app.append_count_digit(c);
1908                continue;
1909            }
1910
1911            // Phase 5.4 — auto-cancel a pending replay confirm on
1912            // any key other than `y` (confirm) or `R` (start a new
1913            // replay). `Esc` is the explicit cancel path and short-
1914            // circuits the quit arm below. Every other key falls
1915            // through to its normal handler after clearing the
1916            // pending state, so navigating away from the prompt
1917            // can't leave a stale `y` primed for the wrong step.
1918            if pending_replay.is_some()
1919                && !matches!(key.code, KeyCode::Char('y') | KeyCode::Char('R'))
1920            {
1921                pending_replay = None;
1922                if matches!(key.code, KeyCode::Esc) {
1923                    app.status_msg = Some("replay cancelled".to_string());
1924                    continue;
1925                }
1926            }
1927
1928            match key.code {
1929                KeyCode::Char('q') | KeyCode::Esc => break,
1930                KeyCode::Char('?') | KeyCode::F(1) => {
1931                    app.clear_count();
1932                    app.toggle_help();
1933                }
1934                KeyCode::Char(':') => {
1935                    app.clear_count();
1936                    app.enter_command_mode();
1937                }
1938                KeyCode::Char('f') => {
1939                    app.clear_count();
1940                    app.enter_filter_mode();
1941                }
1942                KeyCode::Char('/') => {
1943                    app.clear_count();
1944                    app.enter_search_mode();
1945                }
1946                KeyCode::Char('a') => {
1947                    app.clear_count();
1948                    app.enter_annotation_mode();
1949                }
1950                KeyCode::Char('A') => {
1951                    app.clear_count();
1952                    app.toggle_annotations_list();
1953                }
1954                KeyCode::Char('R') => {
1955                    // Phase 5.4 — experimental tool-call replay.
1956                    // Requires `--experimental-replay` and (for
1957                    // Bash) `--allow-shell-replay`. Per-invocation
1958                    // confirm via `y` in the status bar.
1959                    app.clear_count();
1960                    if let Some(view_idx) = app.list_state.selected()
1961                        && let Some(&orig) = app.filtered_view.get(view_idx)
1962                        && let Some(step) = app.steps.get(orig)
1963                    {
1964                        match crate::replay::classify(step, &replay) {
1965                            crate::replay::ReplayIntent::NeedsConfirm { input } => {
1966                                let preview = input.chars().take(60).collect::<String>();
1967                                app.status_msg = Some(format!(
1968                                    "replay step {}: `{preview}` — press y to run, Esc to cancel",
1969                                    orig + 1
1970                                ));
1971                                pending_replay = Some((orig, input));
1972                            }
1973                            crate::replay::ReplayIntent::FlagMissing { hint } => {
1974                                app.status_msg = Some(format!("replay blocked: {hint}"));
1975                            }
1976                            crate::replay::ReplayIntent::NotReplayable { reason } => {
1977                                app.status_msg = Some(format!("replay skipped: {reason}"));
1978                            }
1979                        }
1980                    }
1981                }
1982                KeyCode::Char('y') if pending_replay.is_some() => {
1983                    let (step_idx, input) = pending_replay.take().expect("checked");
1984                    app.status_msg = Some(format!("running replay for step {}…", step_idx + 1));
1985                    match crate::replay::execute_shell(&input) {
1986                        Ok(output) => {
1987                            let exit = output.exit_code.map_or("signal".into(), |c| c.to_string());
1988                            // Best-effort log. A write failure
1989                            // shouldn't block the user from seeing
1990                            // the result in the status bar.
1991                            let log_result = if let Some(path) = session_path
1992                                && let Some(step) = app.steps.get(step_idx)
1993                            {
1994                                crate::replay::log_replay(path, step_idx, step, &input, &output)
1995                            } else {
1996                                Ok(())
1997                            };
1998                            let log_note = match log_result {
1999                                Ok(()) => "logged",
2000                                Err(_) => "log failed",
2001                            };
2002                            // Surface the backpressure flags so a
2003                            // timeout-kill or a 4MiB-capped buffer
2004                            // isn't visually indistinguishable from
2005                            // a normal completion.
2006                            let mut markers: Vec<&str> = Vec::new();
2007                            if output.timed_out {
2008                                markers.push("timed out");
2009                            }
2010                            if output.stdout_truncated {
2011                                markers.push("stdout truncated");
2012                            }
2013                            if output.stderr_truncated {
2014                                markers.push("stderr truncated");
2015                            }
2016                            let suffix = if markers.is_empty() {
2017                                String::new()
2018                            } else {
2019                                format!(" [{}]", markers.join(", "))
2020                            };
2021                            app.status_msg = Some(format!(
2022                                "replay exit={exit} in {}ms ({log_note}); {}B stdout{suffix}",
2023                                output.duration_ms,
2024                                output.stdout.len(),
2025                            ));
2026                        }
2027                        Err(e) => {
2028                            app.status_msg = Some(format!("replay failed: {e}"));
2029                        }
2030                    }
2031                }
2032                KeyCode::Char('b') => {
2033                    app.clear_count();
2034                    app.toggle_forks_list();
2035                }
2036                KeyCode::Char('n') => {
2037                    let n = app.take_count();
2038                    for _ in 0..n {
2039                        app.next_match();
2040                    }
2041                }
2042                KeyCode::Char('N') => {
2043                    let n = app.take_count();
2044                    for _ in 0..n {
2045                        app.prev_match();
2046                    }
2047                }
2048                KeyCode::Char('y') => {
2049                    app.clear_count();
2050                    app.copy_current_step();
2051                }
2052                KeyCode::Char('h') => {
2053                    app.clear_count();
2054                    app.toggle_heatmap();
2055                }
2056                KeyCode::Char('s') => {
2057                    app.clear_count();
2058                    app.toggle_stats();
2059                }
2060                KeyCode::Char('m') => {
2061                    app.clear_count();
2062                    app.begin_set_mark();
2063                }
2064                KeyCode::Char('\'') => {
2065                    app.clear_count();
2066                    app.begin_jump_mark();
2067                }
2068                KeyCode::Tab => {
2069                    app.clear_count();
2070                    app.toggle_layout();
2071                }
2072                KeyCode::Down | KeyCode::Char('j') => {
2073                    let n = app.take_count();
2074                    for _ in 0..n {
2075                        app.next();
2076                    }
2077                }
2078                KeyCode::Up | KeyCode::Char('k') => {
2079                    let n = app.take_count();
2080                    for _ in 0..n {
2081                        app.prev();
2082                    }
2083                }
2084                KeyCode::PageDown | KeyCode::Char('d') => {
2085                    let n = app.take_count();
2086                    app.page_down(PAGE_STEP * n);
2087                }
2088                KeyCode::PageUp | KeyCode::Char('u') => {
2089                    let n = app.take_count();
2090                    app.page_up(PAGE_STEP * n);
2091                }
2092                KeyCode::Home | KeyCode::Char('g') => {
2093                    app.clear_count();
2094                    app.home();
2095                }
2096                KeyCode::End | KeyCode::Char('G') => {
2097                    // 5G in vim = jump to line 5; plain G = last line.
2098                    if app.has_count() {
2099                        let n = app.take_count();
2100                        app.goto_step(n);
2101                    } else {
2102                        app.end();
2103                    }
2104                }
2105                _ => app.clear_count(),
2106            }
2107        }
2108    }
2109    Ok(())
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114    use super::*;
2115    use crate::timeline::{assistant_text_step, tool_result_step, tool_use_step, user_text_step};
2116
2117    #[test]
2118    fn bg_flags_alternate_on_tool_use_and_tool_result() {
2119        let steps = vec![
2120            user_text_step("hi"),
2121            tool_use_step("t1", "Read", "{}"),
2122            tool_result_step("t1", "ok", Some("Read"), Some("{}")),
2123            tool_use_step("t2", "Bash", "{}"),
2124            tool_result_step("t2", "ok", Some("Bash"), Some("{}")),
2125            tool_use_step("t3", "Edit", "{}"),
2126            tool_result_step("t3", "ok", Some("Edit"), Some("{}")),
2127            assistant_text_step("done"),
2128        ];
2129        let flags = compute_bg_flags(&steps);
2130        assert!(!flags[0]);
2131        assert!(!flags[1]);
2132        assert!(!flags[2]);
2133        assert!(flags[3]);
2134        assert!(flags[4]);
2135        assert!(!flags[5]);
2136        assert!(!flags[6]);
2137        assert!(!flags[7]);
2138    }
2139
2140    #[test]
2141    fn bg_flags_empty_for_empty_steps() {
2142        let flags = compute_bg_flags(&[]);
2143        assert!(flags.is_empty());
2144    }
2145
2146    fn sample_steps() -> Vec<Step> {
2147        vec![
2148            user_text_step("write a fibonacci function"),
2149            tool_use_step("t1", "Read", "{}"),
2150            tool_result_step(
2151                "t1",
2152                "def fib(n):\n    return ...",
2153                Some("Read"),
2154                Some("{}"),
2155            ),
2156            tool_use_step("t2", "Bash", "{}"),
2157            tool_result_step("t2", "0 1 1 2 3 5", Some("Bash"), Some("{}")),
2158            assistant_text_step("done"),
2159        ]
2160    }
2161
2162    #[test]
2163    fn goto_step_selects_valid_index() {
2164        let mut app = App::new(sample_steps(), false);
2165        app.goto_step(2);
2166        assert_eq!(app.list_state.selected(), Some(1));
2167    }
2168
2169    #[test]
2170    fn goto_step_clamps_out_of_bounds() {
2171        let mut app = App::new(sample_steps(), false);
2172        app.goto_step(999);
2173        assert_eq!(app.list_state.selected(), Some(5));
2174    }
2175
2176    #[test]
2177    fn goto_step_rejects_zero() {
2178        let mut app = App::new(sample_steps(), false);
2179        app.list_state.select(Some(0));
2180        app.goto_step(0);
2181        assert_eq!(app.list_state.selected(), Some(0));
2182        assert!(app.status_msg.as_ref().unwrap().contains(">= 1"));
2183    }
2184
2185    #[test]
2186    fn execute_command_parses_number() {
2187        let mut app = App::new(sample_steps(), false);
2188        app.execute_command("3");
2189        assert_eq!(app.list_state.selected(), Some(2));
2190    }
2191
2192    #[test]
2193    fn execute_command_ignores_empty_input() {
2194        let mut app = App::new(sample_steps(), false);
2195        app.list_state.select(Some(0));
2196        app.execute_command("   ");
2197        assert_eq!(app.list_state.selected(), Some(0));
2198        assert!(app.status_msg.is_none());
2199    }
2200
2201    #[test]
2202    fn execute_command_reports_unknown() {
2203        let mut app = App::new(sample_steps(), false);
2204        app.execute_command("nope");
2205        assert!(app.status_msg.as_ref().unwrap().contains("unknown"));
2206    }
2207
2208    #[test]
2209    fn execute_command_at_duration_jumps_by_time_offset() {
2210        let mut steps = sample_steps();
2211        // Give the first three steps real timestamps 0 / 5s / 10s
2212        // relative to a synthetic session start.
2213        let base = 1_000_000u64;
2214        steps[0].timestamp_ms = Some(base);
2215        steps[1].timestamp_ms = Some(base + 5_000);
2216        steps[2].timestamp_ms = Some(base + 10_000);
2217        let mut app = App::new(steps, false);
2218        app.execute_command("@5s");
2219        // Step 1 is at +5s → its 0-based index is 1.
2220        assert_eq!(app.list_state.selected(), Some(1));
2221        app.execute_command("@10s");
2222        assert_eq!(app.list_state.selected(), Some(2));
2223    }
2224
2225    #[test]
2226    fn execute_command_at_duration_without_timestamps_is_informative() {
2227        // sample_steps() has no timestamps by default — the jump
2228        // should not silently succeed; it should surface a message.
2229        let mut app = App::new(sample_steps(), false);
2230        app.execute_command("@1h");
2231        let msg = app.status_msg.as_ref().expect("expected a status message");
2232        assert!(
2233            msg.contains("no step timestamps") || msg.contains("unavailable"),
2234            "unexpected status: {msg}"
2235        );
2236    }
2237
2238    #[test]
2239    fn execute_command_at_duration_past_end_reports_no_match() {
2240        let mut steps = sample_steps();
2241        steps[0].timestamp_ms = Some(1_000_000);
2242        let mut app = App::new(steps, false);
2243        app.execute_command("@1h");
2244        // No step has timestamp >= start + 1h → status message, no
2245        // selection change.
2246        let msg = app.status_msg.as_ref().expect("expected a status message");
2247        assert!(msg.contains("no step at-or-after"), "unexpected: {msg}");
2248    }
2249
2250    #[test]
2251    fn execute_command_rejects_bad_duration_spelling() {
2252        let mut steps = sample_steps();
2253        steps[0].timestamp_ms = Some(1_000_000);
2254        let mut app = App::new(steps, false);
2255        app.execute_command("@5x");
2256        assert!(app.status_msg.as_ref().unwrap().contains("bad time offset"));
2257    }
2258
2259    #[test]
2260    fn apply_filter_by_tool_name_substring_case_insensitive() {
2261        let mut app = App::new(sample_steps(), false);
2262        app.apply_filter("read");
2263        assert_eq!(app.visible_count(), 2);
2264        assert_eq!(app.filter.as_deref(), Some("read"));
2265        assert_eq!(app.list_state.selected(), Some(0));
2266    }
2267
2268    #[test]
2269    fn apply_filter_by_kind_prefix() {
2270        let mut app = App::new(sample_steps(), false);
2271        app.apply_filter("[tool]");
2272        assert_eq!(app.visible_count(), 2);
2273    }
2274
2275    #[test]
2276    fn apply_filter_empty_clears_existing_filter() {
2277        let mut app = App::new(sample_steps(), false);
2278        app.apply_filter("Read");
2279        assert_eq!(app.visible_count(), 2);
2280        app.apply_filter("");
2281        assert_eq!(app.visible_count(), 6);
2282        assert!(app.filter.is_none());
2283    }
2284
2285    #[test]
2286    fn apply_filter_no_matches_keeps_previous_view_and_sets_error() {
2287        let mut app = App::new(sample_steps(), false);
2288        app.apply_filter("nonexistent");
2289        assert_eq!(app.visible_count(), 6);
2290        assert!(app.filter.is_none());
2291        assert!(app.status_msg.as_ref().unwrap().contains("no matches"));
2292    }
2293
2294    #[test]
2295    fn clear_filter_restores_full_view() {
2296        let mut app = App::new(sample_steps(), false);
2297        app.apply_filter("Read");
2298        app.clear_filter();
2299        assert_eq!(app.visible_count(), 6);
2300        assert!(app.filter.is_none());
2301        assert_eq!(app.list_state.selected(), Some(0));
2302    }
2303
2304    #[test]
2305    fn navigation_under_filter_operates_on_filtered_view() {
2306        let mut app = App::new(sample_steps(), false);
2307        app.apply_filter("[tool]");
2308        assert_eq!(app.visible_count(), 2);
2309        assert_eq!(app.list_state.selected(), Some(0));
2310        app.next();
2311        assert_eq!(app.list_state.selected(), Some(1));
2312        app.next();
2313        assert_eq!(app.list_state.selected(), Some(1));
2314        app.home();
2315        assert_eq!(app.list_state.selected(), Some(0));
2316        app.end();
2317        assert_eq!(app.list_state.selected(), Some(1));
2318    }
2319
2320    #[test]
2321    fn goto_step_under_filter_uses_visible_positions() {
2322        let mut app = App::new(sample_steps(), false);
2323        app.apply_filter("[tool]");
2324        app.goto_step(2);
2325        assert_eq!(app.list_state.selected(), Some(1));
2326    }
2327
2328    #[test]
2329    fn apply_search_finds_matches_in_label_and_detail() {
2330        let mut app = App::new(sample_steps(), false);
2331        app.apply_search("fib");
2332        // "fibonacci" in user text + "fib(n)" in Read result = 2 matches
2333        assert_eq!(app.search_matches.len(), 2);
2334        assert_eq!(app.search.as_deref(), Some("fib"));
2335    }
2336
2337    #[test]
2338    fn apply_search_case_insensitive() {
2339        let mut app = App::new(sample_steps(), false);
2340        app.apply_search("FIBONACCI");
2341        assert_eq!(app.search_matches.len(), 1);
2342    }
2343
2344    #[test]
2345    fn apply_search_jumps_to_first_match_at_or_after_current() {
2346        let mut app = App::new(sample_steps(), false);
2347        app.list_state.select(Some(2));
2348        app.apply_search("fib");
2349        // Current is step 2 (tool_use Read). Matches: step 0 (user text) and step 2 (Read result).
2350        // Jump should go to step 2 (the at-or-after match)
2351        assert_eq!(app.list_state.selected(), Some(2));
2352    }
2353
2354    #[test]
2355    fn apply_search_empty_clears() {
2356        let mut app = App::new(sample_steps(), false);
2357        app.apply_search("fib");
2358        assert!(!app.search_matches.is_empty());
2359        app.apply_search("");
2360        assert!(app.search.is_none());
2361        assert!(app.search_matches.is_empty());
2362    }
2363
2364    #[test]
2365    fn apply_search_no_matches_sets_error() {
2366        let mut app = App::new(sample_steps(), false);
2367        app.apply_search("zzzzz");
2368        assert!(app.search.is_none());
2369        assert!(app.search_matches.is_empty());
2370        assert!(app.status_msg.as_ref().unwrap().contains("no matches"));
2371    }
2372
2373    #[test]
2374    fn next_match_advances_and_wraps() {
2375        let mut app = App::new(sample_steps(), false);
2376        app.list_state.select(Some(0));
2377        app.apply_search("fib"); // matches 0 and 2
2378        assert_eq!(app.list_state.selected(), Some(0));
2379        app.next_match();
2380        assert_eq!(app.list_state.selected(), Some(2));
2381        app.next_match(); // wrap to first
2382        assert_eq!(app.list_state.selected(), Some(0));
2383    }
2384
2385    #[test]
2386    fn prev_match_goes_back_and_wraps() {
2387        let mut app = App::new(sample_steps(), false);
2388        app.list_state.select(Some(0));
2389        app.apply_search("fib");
2390        assert_eq!(app.list_state.selected(), Some(0));
2391        app.prev_match(); // wrap to last match
2392        assert_eq!(app.list_state.selected(), Some(2));
2393        app.prev_match();
2394        assert_eq!(app.list_state.selected(), Some(0));
2395    }
2396
2397    #[test]
2398    fn search_respects_active_filter() {
2399        let mut app = App::new(sample_steps(), false);
2400        app.apply_filter("[tool]"); // leaves only steps 1 and 3 in filtered_view
2401        app.apply_search("Read"); // should find step 1 (Read tool_use), position 0 in filtered_view
2402        assert_eq!(app.search_matches.len(), 1);
2403        assert_eq!(app.search_matches[0], 0);
2404    }
2405
2406    #[test]
2407    fn clear_search_removes_highlights() {
2408        let mut app = App::new(sample_steps(), false);
2409        app.apply_search("fib");
2410        assert!(!app.search_matches.is_empty());
2411        app.clear_search();
2412        assert!(app.search.is_none());
2413        assert!(app.search_matches.is_empty());
2414    }
2415
2416    #[test]
2417    fn search_matches_recompute_when_filter_changes() {
2418        let mut app = App::new(sample_steps(), false);
2419        app.apply_search("fib"); // matches 2 steps in unfiltered view
2420        assert_eq!(app.search_matches.len(), 2);
2421        app.apply_filter("[tool]"); // filtered_view is now just the 2 tool steps
2422        // "fib" matches step 1 (Read result contains "fib(n)") but Read result is
2423        // not in [tool] filter (it's [result]), and user text not in filter either.
2424        // So 0 matches now.
2425        assert_eq!(app.search_matches.len(), 0);
2426    }
2427
2428    #[test]
2429    fn set_mark_stores_current_step_by_char() {
2430        let mut app = App::new(sample_steps(), false);
2431        app.list_state.select(Some(3));
2432        app.set_mark('a');
2433        assert_eq!(app.bookmarks.get(&'a').copied(), Some(3));
2434        assert!(app.status_msg.as_ref().unwrap().contains("set at step 4"));
2435    }
2436
2437    #[test]
2438    fn jump_to_mark_restores_position() {
2439        let mut app = App::new(sample_steps(), false);
2440        app.list_state.select(Some(2));
2441        app.set_mark('x');
2442        app.list_state.select(Some(0));
2443        app.jump_to_mark('x');
2444        assert_eq!(app.list_state.selected(), Some(2));
2445    }
2446
2447    #[test]
2448    fn jump_to_mark_unknown_char_sets_error() {
2449        let mut app = App::new(sample_steps(), false);
2450        app.jump_to_mark('z');
2451        assert!(app.status_msg.as_ref().unwrap().contains("no bookmark 'z'"));
2452    }
2453
2454    #[test]
2455    fn overwriting_a_bookmark_replaces_position() {
2456        let mut app = App::new(sample_steps(), false);
2457        app.list_state.select(Some(1));
2458        app.set_mark('a');
2459        app.list_state.select(Some(4));
2460        app.set_mark('a');
2461        assert_eq!(app.bookmarks.get(&'a').copied(), Some(4));
2462    }
2463
2464    #[test]
2465    fn multiple_distinct_bookmarks_coexist() {
2466        let mut app = App::new(sample_steps(), false);
2467        app.list_state.select(Some(1));
2468        app.set_mark('a');
2469        app.list_state.select(Some(3));
2470        app.set_mark('b');
2471        app.list_state.select(Some(5));
2472        app.set_mark('c');
2473        app.list_state.select(Some(0));
2474        app.jump_to_mark('b');
2475        assert_eq!(app.list_state.selected(), Some(3));
2476        app.jump_to_mark('a');
2477        assert_eq!(app.list_state.selected(), Some(1));
2478        app.jump_to_mark('c');
2479        assert_eq!(app.list_state.selected(), Some(5));
2480    }
2481
2482    #[test]
2483    fn bookmark_survives_filter_cycle() {
2484        let mut app = App::new(sample_steps(), false);
2485        // Bookmark step 4 (Bash tool_use, original index 3)
2486        app.list_state.select(Some(3));
2487        app.set_mark('b');
2488        // Apply a filter that still includes the bookmarked step
2489        app.apply_filter("[tool]");
2490        assert_eq!(app.visible_count(), 2);
2491        // Bookmark step's original index (3) must be re-findable in filtered_view
2492        app.list_state.select(Some(0));
2493        app.jump_to_mark('b');
2494        // In the filtered view, original step 3 is at filtered position 1
2495        assert_eq!(app.list_state.selected(), Some(1));
2496    }
2497
2498    #[test]
2499    fn jump_to_mark_reports_hidden_by_filter() {
2500        let mut app = App::new(sample_steps(), false);
2501        // Bookmark user text at step 0
2502        app.list_state.select(Some(0));
2503        app.set_mark('u');
2504        // Filter away user text
2505        app.apply_filter("[tool]");
2506        app.jump_to_mark('u');
2507        assert!(
2508            app.status_msg
2509                .as_ref()
2510                .unwrap()
2511                .contains("hidden by filter")
2512        );
2513    }
2514
2515    #[test]
2516    fn cancel_pending_clears_state() {
2517        let mut app = App::new(sample_steps(), false);
2518        app.begin_set_mark();
2519        assert_eq!(app.pending, Some(PendingKey::SetMark));
2520        app.cancel_pending();
2521        assert_eq!(app.pending, None);
2522    }
2523
2524    #[test]
2525    fn click_to_select_sets_index() {
2526        let mut app = App::new(sample_steps(), false);
2527        app.click_to_select(3);
2528        assert_eq!(app.list_state.selected(), Some(3));
2529    }
2530
2531    #[test]
2532    fn click_to_select_clamps_out_of_bounds() {
2533        let mut app = App::new(sample_steps(), false);
2534        app.click_to_select(999);
2535        assert_eq!(app.list_state.selected(), Some(5));
2536    }
2537
2538    #[test]
2539    fn click_to_select_respects_filter() {
2540        let mut app = App::new(sample_steps(), false);
2541        app.apply_filter("[tool]");
2542        assert_eq!(app.visible_count(), 2);
2543        app.click_to_select(1);
2544        assert_eq!(app.list_state.selected(), Some(1));
2545        app.click_to_select(5); // beyond filtered view — clamp
2546        assert_eq!(app.list_state.selected(), Some(1));
2547    }
2548
2549    #[test]
2550    fn conversation_indices_include_only_text_steps() {
2551        let steps = sample_steps();
2552        // sample_steps: user, tool_use, tool_result, tool_use, tool_result, assistant
2553        let indices = compute_conversation_indices(&steps);
2554        assert_eq!(indices, vec![0, 5]);
2555    }
2556
2557    #[test]
2558    fn conversation_indices_empty_for_no_text_steps() {
2559        let steps = vec![
2560            tool_use_step("t1", "Read", "{}"),
2561            tool_result_step("t1", "ok", Some("Read"), Some("{}")),
2562        ];
2563        let indices = compute_conversation_indices(&steps);
2564        assert!(indices.is_empty());
2565    }
2566
2567    #[test]
2568    fn sync_conversation_cursor_on_user_step_selects_user() {
2569        let mut app = App::new(sample_steps(), false);
2570        app.list_state.select(Some(0));
2571        app.sync_conversation_cursor();
2572        assert_eq!(app.conversation_list_state.selected(), Some(0));
2573    }
2574
2575    #[test]
2576    fn sync_conversation_cursor_on_tool_step_selects_prior_text() {
2577        let mut app = App::new(sample_steps(), false);
2578        app.list_state.select(Some(3)); // tool_use Bash
2579        app.sync_conversation_cursor();
2580        // orig=3; rposition finds largest i<=3 in [0, 5] -> 0 (user)
2581        assert_eq!(app.conversation_list_state.selected(), Some(0));
2582    }
2583
2584    #[test]
2585    fn sync_conversation_cursor_on_final_assistant() {
2586        let mut app = App::new(sample_steps(), false);
2587        app.list_state.select(Some(5));
2588        app.sync_conversation_cursor();
2589        assert_eq!(app.conversation_list_state.selected(), Some(1));
2590    }
2591
2592    #[test]
2593    fn toggle_layout_flips_three_pane_flag() {
2594        let mut app = App::new(sample_steps(), false);
2595        assert!(app.three_pane);
2596        app.toggle_layout();
2597        assert!(!app.three_pane);
2598        app.toggle_layout();
2599        assert!(app.three_pane);
2600    }
2601
2602    #[test]
2603    fn batch_flags_mark_consecutive_tool_uses() {
2604        // Three tool_uses in a row followed by three tool_results — a parallel batch.
2605        let steps = vec![
2606            user_text_step("dispatch parallel"),
2607            tool_use_step("t1", "Agent", "{}"),
2608            tool_use_step("t2", "Agent", "{}"),
2609            tool_use_step("t3", "Agent", "{}"),
2610            tool_result_step("t1", "done", Some("Agent"), Some("{}")),
2611            tool_result_step("t2", "done", Some("Agent"), Some("{}")),
2612            tool_result_step("t3", "done", Some("Agent"), Some("{}")),
2613            assistant_text_step("synthesis"),
2614        ];
2615        let flags = compute_batch_flags(&steps);
2616        assert!(!flags[0]); // user text
2617        assert!(flags[1]); // tool_use 1 (batched)
2618        assert!(flags[2]); // tool_use 2 (batched)
2619        assert!(flags[3]); // tool_use 3 (batched)
2620        assert!(flags[4]); // tool_result 1 (batched)
2621        assert!(flags[5]); // tool_result 2 (batched)
2622        assert!(flags[6]); // tool_result 3 (batched)
2623        assert!(!flags[7]); // assistant text
2624    }
2625
2626    #[test]
2627    fn batch_flags_ignore_single_tool_calls() {
2628        // Alternating tool_use / tool_result pattern = no batch.
2629        let steps = vec![
2630            tool_use_step("t1", "Read", "{}"),
2631            tool_result_step("t1", "ok", Some("Read"), Some("{}")),
2632            tool_use_step("t2", "Bash", "{}"),
2633            tool_result_step("t2", "ok", Some("Bash"), Some("{}")),
2634        ];
2635        let flags = compute_batch_flags(&steps);
2636        assert!(!flags[0]);
2637        assert!(!flags[1]);
2638        assert!(!flags[2]);
2639        assert!(!flags[3]);
2640    }
2641
2642    #[test]
2643    fn batch_flags_separate_runs_correctly() {
2644        // First run of 2 tool_uses, then text break, then another run of 2.
2645        let steps = vec![
2646            tool_use_step("t1", "Read", "{}"),
2647            tool_use_step("t2", "Read", "{}"),
2648            assistant_text_step("..."),
2649            tool_use_step("t3", "Bash", "{}"),
2650            tool_use_step("t4", "Bash", "{}"),
2651        ];
2652        let flags = compute_batch_flags(&steps);
2653        assert!(flags[0]);
2654        assert!(flags[1]);
2655        assert!(!flags[2]);
2656        assert!(flags[3]);
2657        assert!(flags[4]);
2658    }
2659
2660    #[test]
2661    fn batch_flags_empty_for_no_steps() {
2662        let flags = compute_batch_flags(&[]);
2663        assert!(flags.is_empty());
2664    }
2665
2666    #[test]
2667    fn count_buffer_accumulates_digits() {
2668        let mut app = App::new(sample_steps(), false);
2669        app.append_count_digit('1');
2670        app.append_count_digit('2');
2671        app.append_count_digit('3');
2672        assert_eq!(app.count_buffer, "123");
2673    }
2674
2675    #[test]
2676    fn count_buffer_rejects_non_digits() {
2677        let mut app = App::new(sample_steps(), false);
2678        app.append_count_digit('a');
2679        assert_eq!(app.count_buffer, "");
2680    }
2681
2682    #[test]
2683    fn count_buffer_caps_length() {
2684        let mut app = App::new(sample_steps(), false);
2685        for _ in 0..10 {
2686            app.append_count_digit('9');
2687        }
2688        assert_eq!(app.count_buffer.len(), 6);
2689    }
2690
2691    #[test]
2692    fn take_count_returns_one_when_empty() {
2693        let mut app = App::new(sample_steps(), false);
2694        assert_eq!(app.take_count(), 1);
2695    }
2696
2697    #[test]
2698    fn take_count_parses_and_clears() {
2699        let mut app = App::new(sample_steps(), false);
2700        app.append_count_digit('3');
2701        app.append_count_digit('7');
2702        assert_eq!(app.take_count(), 37);
2703        assert!(app.count_buffer.is_empty());
2704    }
2705
2706    #[test]
2707    fn take_count_never_returns_zero() {
2708        let mut app = App::new(sample_steps(), false);
2709        app.append_count_digit('0');
2710        // count_buffer == "0" → parses as 0 → clamped to 1
2711        assert_eq!(app.take_count(), 1);
2712    }
2713
2714    #[test]
2715    fn has_count_reflects_buffer_state() {
2716        let mut app = App::new(sample_steps(), false);
2717        assert!(!app.has_count());
2718        app.append_count_digit('5');
2719        assert!(app.has_count());
2720        app.take_count();
2721        assert!(!app.has_count());
2722    }
2723
2724    #[test]
2725    fn count_prefix_multiplies_next_navigation() {
2726        // Simulate the runtime loop behavior: after digits are collected,
2727        // next() should be called take_count() times.
2728        let mut app = App::new(sample_steps(), false);
2729        app.list_state.select(Some(0));
2730        app.append_count_digit('3');
2731        let n = app.take_count();
2732        for _ in 0..n {
2733            app.next();
2734        }
2735        assert_eq!(app.list_state.selected(), Some(3));
2736    }
2737
2738    #[test]
2739    fn toggle_annotations_list_opens_and_closes() {
2740        let mut app = App::new(sample_steps(), false);
2741        app.annotations.set(1, "note on step 2");
2742        assert!(!app.show_annotations);
2743        app.toggle_annotations_list();
2744        assert!(app.show_annotations);
2745        assert_eq!(app.annotations_list_state.selected(), Some(0));
2746        app.toggle_annotations_list();
2747        assert!(!app.show_annotations);
2748    }
2749
2750    #[test]
2751    fn toggle_annotations_list_with_no_notes_opens_with_empty_selection() {
2752        let mut app = App::new(sample_steps(), false);
2753        app.toggle_annotations_list();
2754        assert!(app.show_annotations);
2755        assert_eq!(app.annotations_list_state.selected(), None);
2756    }
2757
2758    #[test]
2759    fn annotations_cursor_move_clamps_to_bounds() {
2760        let mut app = App::new(sample_steps(), false);
2761        app.annotations.set(0, "a");
2762        app.annotations.set(2, "c");
2763        app.annotations.set(4, "e");
2764        app.toggle_annotations_list();
2765        assert_eq!(app.annotations_list_state.selected(), Some(0));
2766        app.annotations_cursor_move(1);
2767        assert_eq!(app.annotations_list_state.selected(), Some(1));
2768        app.annotations_cursor_move(10);
2769        assert_eq!(
2770            app.annotations_list_state.selected(),
2771            Some(2),
2772            "cursor should clamp at the last note"
2773        );
2774        app.annotations_cursor_move(-100);
2775        assert_eq!(app.annotations_list_state.selected(), Some(0));
2776    }
2777
2778    #[test]
2779    fn jump_to_selected_annotation_moves_main_cursor_and_closes_overlay() {
2780        let mut app = App::new(sample_steps(), false);
2781        app.annotations.set(3, "revisit this tool call");
2782        app.toggle_annotations_list();
2783        app.jump_to_selected_annotation();
2784        assert!(!app.show_annotations);
2785        // Step 3 in the underlying steps is 0-indexed → filtered_view
2786        // is identity by default, so selected() should be 3.
2787        assert_eq!(app.list_state.selected(), Some(3));
2788    }
2789
2790    #[cfg(not(feature = "embedding-search"))]
2791    #[test]
2792    fn semantic_prefix_without_feature_shows_rebuild_hint() {
2793        let mut app = App::new(sample_steps(), false);
2794        app.apply_search("//fibonacci");
2795        // Search state untouched; status explains how to enable.
2796        assert!(app.search.is_none());
2797        let msg = app.status_msg.as_deref().unwrap_or("");
2798        assert!(
2799            msg.contains("--features embedding-search"),
2800            "status msg should mention the feature flag, got: {msg}"
2801        );
2802    }
2803
2804    #[cfg(not(feature = "embedding-search"))]
2805    #[test]
2806    fn semantic_prefix_does_not_clear_existing_string_search() {
2807        let mut app = App::new(sample_steps(), false);
2808        app.apply_search("fib"); // valid string-match search
2809        assert!(!app.search_matches.is_empty());
2810        app.apply_search("//anything");
2811        // Existing search preserved (feature-off path only writes status).
2812        assert_eq!(app.search.as_deref(), Some("fib"));
2813        assert!(!app.search_matches.is_empty());
2814    }
2815
2816    #[test]
2817    fn empty_semantic_query_reports_error() {
2818        let mut app = App::new(sample_steps(), false);
2819        app.apply_search("//   ");
2820        let msg = app.status_msg.as_deref().unwrap_or("");
2821        assert!(msg.contains("empty semantic"), "got: {msg}");
2822    }
2823
2824    #[test]
2825    fn apply_initial_step_selects_valid_index() {
2826        let mut app = App::new(sample_steps(), false);
2827        app.apply_initial_step(3);
2828        assert_eq!(app.list_state.selected(), Some(3));
2829        assert!(app.status_msg.is_none());
2830    }
2831
2832    #[test]
2833    fn apply_initial_step_clamps_out_of_range_with_warning() {
2834        let mut app = App::new(sample_steps(), false);
2835        let last = sample_steps().len() - 1;
2836        app.apply_initial_step(999);
2837        assert_eq!(app.list_state.selected(), Some(last));
2838        let msg = app.status_msg.as_deref().unwrap_or("");
2839        assert!(
2840            msg.contains("out of range") && msg.contains("clamped"),
2841            "expected clamp warning, got: {msg}"
2842        );
2843    }
2844
2845    #[test]
2846    fn apply_initial_step_zero_is_fine() {
2847        let mut app = App::new(sample_steps(), false);
2848        app.apply_initial_step(0);
2849        assert_eq!(app.list_state.selected(), Some(0));
2850        assert!(app.status_msg.is_none());
2851    }
2852
2853    #[test]
2854    fn apply_initial_step_noop_on_empty_steps() {
2855        let mut app = App::new(Vec::new(), false);
2856        app.apply_initial_step(5);
2857        // Empty session → no selection, no status message (nothing to
2858        // warn about because the session is simply empty, which the
2859        // rest of the TUI already handles).
2860        assert_eq!(app.list_state.selected(), None);
2861    }
2862
2863    #[test]
2864    fn toggle_forks_list_opens_and_closes() {
2865        let mut app = App::new(sample_steps(), false);
2866        // Synthetic: mark step 2 as a fork root so the overlay has content.
2867        app.fork_indices = vec![2];
2868        assert!(!app.show_forks);
2869        app.toggle_forks_list();
2870        assert!(app.show_forks);
2871        assert_eq!(app.forks_list_state.selected(), Some(0));
2872        app.toggle_forks_list();
2873        assert!(!app.show_forks);
2874    }
2875
2876    #[test]
2877    fn toggle_forks_list_with_no_forks_opens_empty() {
2878        let mut app = App::new(sample_steps(), false);
2879        // fork_indices is empty by default for synthetic sample_steps.
2880        app.toggle_forks_list();
2881        assert!(app.show_forks);
2882        assert_eq!(app.forks_list_state.selected(), None);
2883    }
2884
2885    #[test]
2886    fn jump_to_selected_fork_moves_main_cursor_and_closes_overlay() {
2887        let mut app = App::new(sample_steps(), false);
2888        app.fork_indices = vec![1, 3];
2889        app.toggle_forks_list();
2890        // Selected by default = 0 → fork at step 1.
2891        app.jump_to_selected_fork();
2892        assert!(!app.show_forks);
2893        assert_eq!(app.list_state.selected(), Some(1));
2894    }
2895
2896    #[test]
2897    fn jump_to_filtered_fork_reports_hidden_via_status() {
2898        let mut app = App::new(sample_steps(), false);
2899        app.fork_indices = vec![3];
2900        // Filter to rows whose label starts with "[user]" — hides step 3.
2901        app.apply_filter("write");
2902        app.toggle_forks_list();
2903        app.jump_to_selected_fork();
2904        assert!(!app.show_forks);
2905        assert!(
2906            app.status_msg
2907                .as_deref()
2908                .unwrap_or("")
2909                .contains("hidden by the active filter"),
2910            "expected filter-hidden status, got {:?}",
2911            app.status_msg
2912        );
2913    }
2914
2915    #[test]
2916    fn apply_initial_step_respects_active_filter() {
2917        let mut app = App::new(sample_steps(), false);
2918        // Filter to just tool-use rows (there are 2 in sample_steps).
2919        app.apply_filter("[tool]");
2920        assert_eq!(app.filtered_view.len(), 2);
2921        // Out-of-range relative to filter → clamps to last filtered row.
2922        app.apply_initial_step(5);
2923        assert_eq!(app.list_state.selected(), Some(1));
2924        let msg = app.status_msg.as_deref().unwrap_or("");
2925        assert!(
2926            msg.contains("2 steps"),
2927            "expected filtered count, got: {msg}"
2928        );
2929    }
2930
2931    #[test]
2932    fn jump_to_filtered_annotation_reports_hidden_via_status() {
2933        let mut app = App::new(sample_steps(), false);
2934        app.annotations.set(3, "filtered");
2935        // Filter to rows that don't contain step 3's label — sample_steps()
2936        // step 3 is a tool_use for Bash; filter on "write" (user text)
2937        // to hide it.
2938        app.apply_filter("write");
2939        app.toggle_annotations_list();
2940        app.jump_to_selected_annotation();
2941        assert!(!app.show_annotations);
2942        assert!(
2943            app.status_msg
2944                .as_deref()
2945                .unwrap_or("")
2946                .contains("hidden by the active filter"),
2947            "expected filter-hidden status, got {:?}",
2948            app.status_msg
2949        );
2950    }
2951}