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 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 show_annotations: bool,
79 annotations_list_state: ListState,
80 show_forks: bool,
85 forks_list_state: ListState,
86 fork_indices: Vec<usize>,
87 no_cost: bool,
90 session_totals: SessionTotals,
91 annotations: crate::annotations::Annotations,
96 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
855fn 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
881fn 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
894fn 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 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#[derive(Debug, Clone, Copy, Default)]
962pub struct NotifyConfig {
963 pub on_error: bool,
966 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 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 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#[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 let mut last_growth = std::time::Instant::now();
1032 let mut idle_fired = false;
1033 let mut pending_replay: Option<(usize, String)> = None;
1037 loop {
1038 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 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 let _ = crate::notify::error(&label);
1064 }
1065 }
1066
1067 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 app.sync_conversation_cursor();
1080
1081 let outer = Layout::default()
1083 .direction(Direction::Vertical)
1084 .constraints([Constraint::Min(1), Constraint::Length(1)])
1085 .split(f.area());
1086
1087 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 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 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 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 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 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 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 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 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 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 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 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 if app.show_help {
1808 app.show_help = false;
1809 continue;
1810 }
1811
1812 if app.show_stats {
1814 app.show_stats = false;
1815 continue;
1816 }
1817
1818 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 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 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 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 app.status_msg = None;
1901
1902 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 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 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 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 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 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 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 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 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 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 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 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"); 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(); 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(); 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]"); app.apply_search("Read"); 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"); assert_eq!(app.search_matches.len(), 2);
2421 app.apply_filter("[tool]"); 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 app.list_state.select(Some(3));
2487 app.set_mark('b');
2488 app.apply_filter("[tool]");
2490 assert_eq!(app.visible_count(), 2);
2491 app.list_state.select(Some(0));
2493 app.jump_to_mark('b');
2494 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 app.list_state.select(Some(0));
2503 app.set_mark('u');
2504 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); 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 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)); app.sync_conversation_cursor();
2580 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 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]); assert!(flags[1]); assert!(flags[2]); assert!(flags[3]); assert!(flags[4]); assert!(flags[5]); assert!(flags[6]); assert!(!flags[7]); }
2625
2626 #[test]
2627 fn batch_flags_ignore_single_tool_calls() {
2628 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 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 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 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 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 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"); assert!(!app.search_matches.is_empty());
2810 app.apply_search("//anything");
2811 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 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 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 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 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 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 app.apply_filter("[tool]");
2920 assert_eq!(app.filtered_view.len(), 2);
2921 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 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}