1mod controls;
7mod conversation;
8mod graph;
9mod theme_picker;
10mod timeline;
11
12use crate::client::RemoteRuns;
13use crate::format::{format_duration, parse_timestamp_ms, sanitize_text};
14use crate::layout::GraphLayout;
15use crate::protocol::PageKind;
16use crate::render::{
17 render_graph, render_graph_with_layout, GraphNodeStyle, GraphView, NodeBounds, RenderedGraph,
18};
19use crate::session::{assess_capture, CaptureIntegrity};
20use crate::source::RunSource;
21use crate::state::reader::with_artifact_placeholders;
22use crate::state::types::{
23 DefinitionSnapshot, EdgeDef, NodeOutcome, RunState, RunStatus, SessionCapture,
24 SessionEntryRecord, SessionEventRecord, StepRecord, SESSION_BINDING_SCHEMA,
25};
26use crate::theme::{self, Palette, ThemeConfig};
27use anyhow::Result;
28use crossterm::event::{
29 DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
30 MouseButton, MouseEvent, MouseEventKind,
31};
32use ratatui::layout::{Constraint, Direction, Layout, Rect};
33use ratatui::style::{Modifier, Style, Stylize as _};
34use ratatui::text::{Line, Span};
35use ratatui::widgets::{Block, Borders, Paragraph};
36use ratatui::Frame;
37use serde_json::Value;
38use std::collections::{HashMap, HashSet};
39use std::path::Path;
40use std::time::{Duration, Instant};
41
42const LOCAL_REFRESH_INTERVAL: Duration = Duration::from_millis(300);
43const PLAY_STEP_INTERVAL: Duration = Duration::from_millis(700);
44const DEFAULT_NODE_STYLE: GraphNodeStyle = GraphNodeStyle::Box;
45const DEFAULT_SIDEBAR_WIDTH: u16 = 34;
46const MIN_SIDEBAR_WIDTH: u16 = 12;
47const MIN_MAIN_WIDTH: u16 = 24;
48const MIN_GRAPH_HEIGHT: u16 = 5;
49const MIN_INSPECTOR_HEIGHT: u16 = 5;
50
51pub struct RunSummary {
52 pub run_id: String,
53 pub workflow_name: String,
54 pub run_title: Option<String>,
55 pub status: RunStatus,
56 pub started_at: String,
57 pub finished_at: Option<String>,
58 pub live: bool,
59 pub possibly_interrupted: bool,
60}
61
62pub struct RunData<'a> {
64 pub graph_revision: u64,
65 pub state: &'a RunState,
66 pub graph_steps: &'a [crate::state::types::StepRecord],
67 pub taken_transitions: &'a [String],
68 pub graph_cursor: u64,
69 pub step_start: u64,
70 pub step_total: u64,
71 pub snapshot: Option<&'a DefinitionSnapshot>,
72 pub graph_layout: Option<&'a GraphLayout>,
73 pub events: &'a [Value],
74 pub trace_start: u64,
75 pub trace_total: u64,
76 pub session_bound: bool,
77 pub session_entries: &'a [Value],
78 pub session_entry_start: u64,
79 pub session_entry_total: u64,
80 pub session_events: &'a [Value],
81 pub session_event_start: u64,
82 pub session_event_total: u64,
83 pub session_events_malformed: bool,
84 pub session_events_torn_tail: bool,
85 pub session_capture: Option<&'a Value>,
86 pub session_replay_checkpoint: Option<&'a Value>,
87 pub settings_scopes: &'a [Value],
88 pub settings_start: u64,
89 pub settings_total: u64,
90 pub follow_up_queue: Option<&'a Value>,
91 pub follow_up_start: u64,
92 pub follow_up_total: u64,
93 pub update_start: u64,
94 pub update_total: u64,
95 pub live: bool,
96 pub possibly_interrupted: bool,
97 pub run_dir: Option<&'a std::path::Path>,
100 pub remote_artifacts: HashMap<String, std::result::Result<String, String>>,
101}
102
103pub enum Provider {
104 Local {
105 source: Box<RunSource>,
106 last_refresh: Instant,
107 },
108 Remote(RemoteRuns),
109}
110
111fn valid_session_binding(binding: Option<&Value>) -> bool {
112 binding
113 .and_then(|value| value.get("schema"))
114 .and_then(Value::as_str)
115 == Some(SESSION_BINDING_SCHEMA)
116}
117
118fn parse_run_summary(summary: &Value) -> Option<RunSummary> {
119 let manifest: crate::state::types::Manifest =
120 serde_json::from_value(summary.get("manifest")?.clone()).ok()?;
121 Some(RunSummary {
122 run_id: manifest.run_id,
123 workflow_name: manifest.workflow_name,
124 run_title: manifest.run_title,
125 status: manifest.status,
126 started_at: manifest.started_at,
127 finished_at: manifest.finished_at,
128 live: summary
129 .get("live")
130 .and_then(Value::as_bool)
131 .unwrap_or(false),
132 possibly_interrupted: summary
133 .get("possiblyInterrupted")
134 .and_then(Value::as_bool)
135 .unwrap_or(false),
136 })
137}
138
139impl Provider {
140 fn tick(&mut self) {
141 if let Provider::Local {
142 source,
143 last_refresh,
144 } = self
145 {
146 source.drain();
147 if last_refresh.elapsed() >= LOCAL_REFRESH_INTERVAL {
148 source.refresh_all();
149 *last_refresh = Instant::now();
150 }
151 }
152 }
153
154 fn ensure_watch(&mut self, run_id: &str) {
155 match self {
156 Provider::Local { source, .. } => {
157 let _ = source.select(run_id);
158 }
159 Provider::Remote(remote) => remote.watch(run_id),
160 }
161 }
162
163 fn summaries(&self) -> Vec<RunSummary> {
164 match self {
165 Provider::Local { source, .. } => source
166 .summaries()
167 .iter()
168 .filter_map(parse_run_summary)
169 .collect(),
170 Provider::Remote(remote) => remote
171 .summaries()
172 .iter()
173 .filter_map(parse_run_summary)
174 .collect(),
175 }
176 }
177
178 fn data(&mut self, run_id: &str) -> Option<RunData<'_>> {
179 match self {
180 Provider::Local { source, .. } => {
181 let entry = source.get(run_id)?;
182 Some(RunData {
183 graph_revision: entry.graph_revision,
184 state: &entry.state,
185 graph_steps: &entry.graph_steps,
186 taken_transitions: &entry.taken_transitions,
187 graph_cursor: entry.graph_cursor,
188 step_start: entry.step_start,
189 step_total: entry.step_total,
190 snapshot: entry.snapshot.as_ref(),
191 graph_layout: entry.graph_layout.as_ref(),
192 events: &entry.events,
193 trace_start: entry.trace_start,
194 trace_total: entry.trace_total,
195 session_bound: valid_session_binding(entry.session_binding.as_ref()),
196 session_entries: &entry.session_entries,
197 session_entry_start: entry.session_entry_start,
198 session_entry_total: entry.session_entry_total,
199 session_events: &entry.session_events,
200 session_event_start: entry.session_event_start,
201 session_event_total: entry.session_event_total,
202 session_events_malformed: entry.session_events_malformed,
203 session_events_torn_tail: entry.session_events_torn_tail,
204 session_capture: entry.session_capture.as_ref(),
205 session_replay_checkpoint: entry.session_replay_checkpoint.as_ref(),
206 settings_scopes: &entry.settings_scopes,
207 settings_start: entry.settings_start,
208 settings_total: entry.settings_total,
209 follow_up_queue: entry.follow_up_queue.as_ref(),
210 follow_up_start: entry.follow_up_start,
211 follow_up_total: entry.follow_up_total,
212 update_start: entry.update_start,
213 update_total: entry.update_total,
214 live: entry.live,
215 possibly_interrupted: entry.possibly_interrupted,
216 run_dir: Some(&entry.dir),
217 remote_artifacts: HashMap::new(),
218 })
219 }
220 Provider::Remote(remote) => {
221 let remote_artifacts = remote.artifact_snapshot(run_id);
222 let view = remote.view(run_id)?;
223 Some(RunData {
224 graph_revision: view.graph_revision,
225 state: &view.state,
226 graph_steps: &view.graph_steps,
227 taken_transitions: &view.taken_transitions,
228 graph_cursor: view.graph_cursor,
229 step_start: view.step_start,
230 step_total: view.step_total,
231 snapshot: view.snapshot.as_ref(),
232 graph_layout: view.graph_layout.as_ref(),
233 events: &view.events,
234 trace_start: view.trace_start,
235 trace_total: view.trace_total,
236 session_bound: valid_session_binding(view.session_binding.as_ref()),
237 session_entries: &view.session_entries,
238 session_entry_start: view.session_entry_start,
239 session_entry_total: view.session_entry_total,
240 session_events: &view.session_events,
241 session_event_start: view.session_event_start,
242 session_event_total: view.session_event_total,
243 session_events_malformed: view.session_events_malformed,
244 session_events_torn_tail: view.session_events_torn_tail,
245 session_capture: view.session_capture.as_ref(),
246 session_replay_checkpoint: view.session_replay_checkpoint.as_ref(),
247 settings_scopes: &view.settings_scopes,
248 settings_start: view.settings_start,
249 settings_total: view.settings_total,
250 follow_up_queue: view.follow_up_queue.as_ref(),
251 follow_up_start: view.follow_up_start,
252 follow_up_total: view.follow_up_total,
253 update_start: view.update_start,
254 update_total: view.update_total,
255 live: view.live,
256 possibly_interrupted: view.possibly_interrupted,
257 run_dir: None,
258 remote_artifacts,
259 })
260 }
261 }
262 }
263
264 fn request_window(
265 &mut self,
266 run_id: &str,
267 step: Option<u64>,
268 trace: Option<u64>,
269 session_entry: Option<u64>,
270 session_event: Option<u64>,
271 ) {
272 match self {
273 Provider::Local { source, .. } => {
274 let mut cursor = source.cursor(run_id);
275 if let Some(value) = step {
276 cursor.step = Some(value);
277 cursor.trace_step = Some(value);
278 }
279 if let Some(value) = trace {
280 cursor.trace = Some(value);
281 cursor.trace_step = None;
282 }
283 if let Some(value) = session_entry {
284 cursor.session_entry = Some(value);
285 }
286 if let Some(value) = session_event {
287 cursor.session_event = Some(value);
288 }
289 let _ = source.request_window(run_id, cursor);
290 }
291 Provider::Remote(remote) => {
292 if let Some(cursor) = step {
293 remote.request_page(run_id, PageKind::Steps, cursor);
294 remote.request_page(run_id, PageKind::TraceAtStep, cursor);
295 }
296 if let Some(cursor) = trace {
297 remote.request_page(run_id, PageKind::Trace, cursor);
298 }
299 if let Some(cursor) = session_entry {
300 remote.request_page(run_id, PageKind::SessionEntries, cursor);
301 }
302 if let Some(cursor) = session_event {
303 remote.request_page(run_id, PageKind::SessionEvents, cursor);
304 }
305 }
306 }
307 }
308
309 fn request_info_window(
310 &mut self,
311 run_id: &str,
312 settings: Option<u64>,
313 follow_ups: Option<u64>,
314 updates: Option<u64>,
315 ) {
316 match self {
317 Provider::Local { source, .. } => {
318 let mut cursor = source.cursor(run_id);
319 if let Some(value) = settings {
320 cursor.settings = Some(value);
321 }
322 if let Some(value) = follow_ups {
323 cursor.follow_ups = Some(value);
324 }
325 if let Some(value) = updates {
326 cursor.updates = Some(value);
327 }
328 let _ = source.request_window(run_id, cursor);
329 }
330 Provider::Remote(remote) => {
331 if let Some(cursor) = settings {
332 remote.request_page(run_id, PageKind::Settings, cursor);
333 }
334 if let Some(cursor) = follow_ups {
335 remote.request_page(run_id, PageKind::FollowUps, cursor);
336 }
337 if let Some(cursor) = updates {
338 remote.request_page(run_id, PageKind::Updates, cursor);
339 }
340 }
341 }
342 }
343
344 fn request_artifacts(&mut self, run_id: &str, paths: &[String]) {
345 if let Provider::Remote(remote) = self {
346 for path in paths {
347 remote.request_artifact(run_id, path);
348 }
349 }
350 }
351}
352
353#[derive(Clone, Copy, PartialEq, Eq)]
354enum Focus {
355 Runs,
356 Graph,
357 Inspector,
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361enum InspectorTab {
362 Steps,
363 Trace,
364 Conversation,
365 Info,
366}
367
368impl InspectorTab {
369 fn next(self) -> Self {
370 match self {
371 InspectorTab::Steps => InspectorTab::Trace,
372 InspectorTab::Trace => InspectorTab::Conversation,
373 InspectorTab::Conversation => InspectorTab::Info,
374 InspectorTab::Info => InspectorTab::Steps,
375 }
376 }
377
378 fn index(self) -> usize {
379 match self {
380 InspectorTab::Steps => 0,
381 InspectorTab::Trace => 1,
382 InspectorTab::Conversation => 2,
383 InspectorTab::Info => 3,
384 }
385 }
386
387 const ALL: [Self; 4] = [Self::Steps, Self::Trace, Self::Conversation, Self::Info];
388
389 fn title(self) -> &'static str {
390 match self {
391 InspectorTab::Steps => "Steps",
392 InspectorTab::Trace => "Trace",
393 InspectorTab::Conversation => "Conversation",
394 InspectorTab::Info => "Info",
395 }
396 }
397
398 fn symbol(self) -> &'static str {
399 match self {
400 InspectorTab::Steps => "◆",
401 InspectorTab::Trace => "≡",
402 InspectorTab::Conversation => "●",
403 InspectorTab::Info => "ⓘ",
404 }
405 }
406}
407
408#[derive(Debug, Clone, Copy)]
409struct InspectorTabHit {
410 rect: Rect,
411 tab: InspectorTab,
412}
413
414#[derive(Clone, Copy, PartialEq, Eq)]
415enum TraceScope {
416 SelectedAttempt,
417 ReplayVisible,
418 LoadedPage,
419}
420
421impl TraceScope {
422 fn next(self) -> Self {
423 match self {
424 Self::SelectedAttempt => Self::ReplayVisible,
425 Self::ReplayVisible => Self::LoadedPage,
426 Self::LoadedPage => Self::SelectedAttempt,
427 }
428 }
429
430 fn label(self) -> &'static str {
431 match self {
432 Self::SelectedAttempt => "selected attempt",
433 Self::ReplayVisible => "replay visible",
434 Self::LoadedPage => "loaded page",
435 }
436 }
437}
438
439#[derive(Clone, Copy)]
440enum DragTarget {
441 Graph {
442 start_x: u16,
443 start_y: u16,
444 origin_x: i64,
445 origin_y: i64,
446 },
447 Sidebar,
448 Inspector,
449}
450
451#[derive(Clone, PartialEq, Eq)]
452struct GraphCacheKey {
453 run_id: String,
454 graph_revision: u64,
455 replay_position: i64,
456 graph_cursor: u64,
457 at_latest: bool,
458 node_style: GraphNodeStyle,
459 elapsed_second: i64,
460}
461
462struct GraphCache {
463 key: GraphCacheKey,
464 rendered: Option<RenderedGraph>,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468enum TemporalDelay {
469 Ready(Duration),
470 Pending(u64),
471 Invalid,
472}
473
474struct App {
475 provider: Provider,
476 show_sidebar: bool,
478 sidebar_collapsed: bool,
479 sidebar_explicit: bool,
480 sidebar_width: u16,
481 inspector_height: Option<u16>,
482 selected_run: Option<String>,
483 runs_scroll: usize,
484 focus: Focus,
485 replay: Option<i64>,
487 temporal_replay: Option<i64>,
490 playing: bool,
491 last_play_step: Instant,
492 playback_speed_index: usize,
493 node_style: GraphNodeStyle,
494 follow: bool,
495 graph_offset: (i64, i64),
498 graph_nodes: Vec<NodeBounds>,
499 graph_cache: Option<GraphCache>,
500 dragging: Option<DragTarget>,
501 tab: InspectorTab,
502 inspector_scroll: usize,
503 inspector_scrolls: [usize; 4],
504 inspector_expanded: bool,
505 trace_scope: TraceScope,
506 trace_selected: usize,
507 trace_payload_expanded: bool,
508 conversation_follow: bool,
509 conversation_selected: usize,
510 conversation_payload_expanded: bool,
511 palette: Palette,
512 theme_config: ThemeConfig,
513 theme_config_path: std::path::PathBuf,
514 theme_picker: Option<theme_picker::ThemePicker>,
515 theme_diagnostic: Option<String>,
516 frame_rect: Rect,
518 main_rect: Rect,
519 runs_rect: Rect,
520 timeline: timeline::TimelineGeometry,
521 graph_rect: Rect,
522 inspector_rect: Rect,
523 inspector_tab_hits: Vec<InspectorTabHit>,
524 quit: bool,
525}
526
527pub fn run_local(database_path: &Path, cli_theme: Option<&str>) -> Result<()> {
528 let source = RunSource::new(database_path)?;
529 run_app(
530 Provider::Local {
531 source: Box::new(source),
532 last_refresh: Instant::now(),
533 },
534 true,
535 cli_theme,
536 )
537}
538
539pub fn run_single(database_path: &Path, run_id: &str, cli_theme: Option<&str>) -> Result<()> {
540 let source = RunSource::single(database_path, run_id)?;
541 run_app(
542 Provider::Local {
543 source: Box::new(source),
544 last_refresh: Instant::now(),
545 },
546 false,
547 cli_theme,
548 )
549}
550
551pub fn run_remote(url: &str, cli_theme: Option<&str>) -> Result<()> {
552 let remote = RemoteRuns::connect(url)?;
553 run_app(Provider::Remote(remote), true, cli_theme)
554}
555
556fn run_app(provider: Provider, show_sidebar: bool, cli_theme: Option<&str>) -> Result<()> {
557 let resolved_theme = theme::resolve(cli_theme);
558 let mut terminal = ratatui::init();
559 if let Err(error) = crossterm::execute!(std::io::stdout(), EnableMouseCapture) {
560 ratatui::restore();
561 return Err(error.into());
562 }
563 let result = event_loop(&mut terminal, provider, show_sidebar, resolved_theme);
564 let _ = crossterm::execute!(std::io::stdout(), DisableMouseCapture);
565 ratatui::restore();
566 result
567}
568
569fn event_loop(
570 terminal: &mut ratatui::DefaultTerminal,
571 provider: Provider,
572 show_sidebar: bool,
573 resolved_theme: theme::ResolvedTheme,
574) -> Result<()> {
575 let sidebar_width = resolved_theme
576 .ui
577 .sidebar_width
578 .unwrap_or(DEFAULT_SIDEBAR_WIDTH);
579 let inspector_height = resolved_theme.ui.inspector_height;
580 let mut app = App {
581 provider,
582 show_sidebar,
583 sidebar_collapsed: false,
584 sidebar_explicit: false,
585 sidebar_width,
586 inspector_height,
587 selected_run: None,
588 runs_scroll: 0,
589 focus: if show_sidebar {
590 Focus::Runs
591 } else {
592 Focus::Graph
593 },
594 replay: None,
595 temporal_replay: None,
596 playing: false,
597 last_play_step: Instant::now(),
598 playback_speed_index: 0,
599 node_style: DEFAULT_NODE_STYLE,
600 follow: true,
601 graph_offset: (0, 0),
602 graph_nodes: Vec::new(),
603 graph_cache: None,
604 dragging: None,
605 tab: InspectorTab::Steps,
606 inspector_scroll: 0,
607 inspector_scrolls: [0; 4],
608 inspector_expanded: false,
609 trace_scope: TraceScope::SelectedAttempt,
610 trace_selected: 0,
611 trace_payload_expanded: false,
612 conversation_follow: true,
613 conversation_selected: 0,
614 conversation_payload_expanded: false,
615 palette: resolved_theme.palette,
616 theme_config: resolved_theme.config,
617 theme_config_path: resolved_theme.config_path,
618 theme_picker: None,
619 theme_diagnostic: resolved_theme.diagnostics.into_iter().next(),
620 frame_rect: Rect::default(),
621 main_rect: Rect::default(),
622 runs_rect: Rect::default(),
623 timeline: timeline::TimelineGeometry::default(),
624 graph_rect: Rect::default(),
625 inspector_rect: Rect::default(),
626 inspector_tab_hits: Vec::new(),
627 quit: false,
628 };
629
630 while !app.quit {
631 app.provider.tick();
632 let summaries = app.provider.summaries();
633 if app.selected_run.is_none()
634 || !summaries
635 .iter()
636 .any(|summary| Some(&summary.run_id) == app.selected_run.as_ref())
637 {
638 app.selected_run = summaries.first().map(|summary| summary.run_id.clone());
639 }
640 if let Some(run_id) = app.selected_run.clone() {
641 app.provider.ensure_watch(&run_id);
642 }
643 app.ensure_step_window();
644 app.ensure_replay_window();
645 app.advance_playback();
646 terminal.draw(|frame| draw(frame, &mut app, &summaries))?;
647
648 if crossterm::event::poll(Duration::from_millis(120))? {
649 match crossterm::event::read()? {
650 Event::Key(key) if key.kind != KeyEventKind::Release => {
651 handle_key(&mut app, &summaries, key);
652 }
653 Event::Mouse(mouse) => handle_mouse(&mut app, &summaries, mouse),
654 _ => {}
655 }
656 }
657 }
658 Ok(())
659}
660
661impl App {
662 fn ensure_step_window(&mut self) {
663 let (Some(run_id), Some(position)) = (
664 self.selected_run.clone(),
665 self.replay.filter(|value| *value >= 0),
666 ) else {
667 return;
668 };
669 let cursor = position as u64;
670 let loaded = self.provider.data(&run_id).is_some_and(|data| {
671 step_projection_contains(
672 cursor,
673 data.graph_cursor,
674 data.step_start,
675 data.state.steps.len(),
676 )
677 });
678 if !loaded {
679 self.provider
680 .request_window(&run_id, Some(cursor), None, None, None);
681 }
682 }
683
684 fn ensure_replay_window(&mut self) {
685 let (Some(run_id), Some(position)) = (
686 self.selected_run.clone(),
687 self.temporal_replay.filter(|value| *value >= 0),
688 ) else {
689 return;
690 };
691 let cursor = position as u64;
692 let loaded = self.provider.data(&run_id).is_some_and(|data| {
693 cursor >= data.session_event_start
694 && cursor < data.session_event_start + data.session_events.len() as u64
695 });
696 if loaded {
697 self.sync_step_to_temporal();
698 } else {
699 self.provider
700 .request_window(&run_id, None, None, Some(cursor), Some(cursor));
701 }
702 }
703
704 fn replay_counts(&mut self) -> (i64, i64, bool) {
705 let Some(run_id) = self.selected_run.clone() else {
706 return (0, 0, false);
707 };
708 self.provider
709 .data(&run_id)
710 .map(|data| {
711 (
712 data.step_total as i64,
713 data.session_event_total as i64,
714 data.live,
715 )
716 })
717 .unwrap_or((0, 0, false))
718 }
719
720 fn temporal_delay(&mut self, current: i64, speed: u32) -> TemporalDelay {
721 let Some(run_id) = self.selected_run.clone() else {
722 return TemporalDelay::Invalid;
723 };
724 let delay = self
725 .provider
726 .data(&run_id)
727 .map_or(TemporalDelay::Invalid, |data| {
728 temporal_delay_from_page(
729 data.session_events,
730 data.session_event_start,
731 current,
732 speed,
733 )
734 });
735 if let TemporalDelay::Pending(cursor) = delay {
736 self.provider
737 .request_window(&run_id, None, None, Some(cursor), Some(cursor));
738 }
739 delay
740 }
741
742 fn sync_step_to_temporal(&mut self) {
743 let Some(position) = self.temporal_replay else {
744 return;
745 };
746 if position < 0 {
747 self.replay = Some(-1);
748 return;
749 }
750 let Some(run_id) = self.selected_run.clone() else {
751 return;
752 };
753 let selected = {
754 let Some(data) = self.provider.data(&run_id) else {
755 return;
756 };
757 let Ok(position) = u64::try_from(position) else {
758 return;
759 };
760 let Some(local) = position.checked_sub(data.session_event_start) else {
761 return;
762 };
763 let Some(event) = data.session_events.get(local as usize) else {
764 return;
765 };
766 event
767 .get("stepIndex")
768 .and_then(Value::as_i64)
769 .unwrap_or_else(|| {
770 event
771 .get("at")
772 .and_then(Value::as_str)
773 .and_then(parse_timestamp_ms)
774 .map_or(-1, |event_at| {
775 let local = completed_step_at(&data.state.steps, event_at);
776 if local < 0 {
777 -1
778 } else {
779 data.step_start as i64 + local
780 }
781 })
782 })
783 };
784 self.replay = Some(selected);
785 }
786
787 fn advance_playback(&mut self) {
788 if !self.playing {
789 return;
790 }
791 let speed = u32::from(timeline::PLAYBACK_SPEEDS[self.playback_speed_index]);
792 let (steps, temporal_events, live) = self.replay_counts();
793 if temporal_events > 0 {
794 for _ in 0..256 {
797 let current = self.temporal_replay.unwrap_or(-1);
798 if current + 1 >= temporal_events {
799 if !live {
800 self.rejoin_live();
801 }
802 return;
803 }
804 let interval = match self.temporal_delay(current, speed) {
805 TemporalDelay::Ready(interval) => interval,
806 TemporalDelay::Pending(_) => return,
807 TemporalDelay::Invalid => {
808 self.playing = false;
809 return;
810 }
811 };
812 if self.last_play_step.elapsed() < interval {
813 return;
814 }
815 self.last_play_step += interval;
816 self.temporal_replay = Some(current + 1);
817 self.sync_step_to_temporal();
818 }
819 return;
820 }
821 let interval = PLAY_STEP_INTERVAL / speed;
822 if self.last_play_step.elapsed() < interval {
823 return;
824 }
825 self.last_play_step = Instant::now();
826 match self.replay {
827 Some(position) if position + 1 < steps => self.replay = Some(position + 1),
828 _ => self.rejoin_live(),
829 }
830 }
831
832 fn rejoin_live(&mut self) {
833 self.replay = None;
834 self.temporal_replay = None;
835 self.playing = false;
836 self.follow = true;
837 self.conversation_follow = true;
838 }
839
840 fn slower_playback(&mut self) {
841 self.playback_speed_index = self.playback_speed_index.saturating_sub(1);
842 }
843
844 fn faster_playback(&mut self) {
845 self.playback_speed_index =
846 (self.playback_speed_index + 1).min(timeline::PLAYBACK_SPEEDS.len() - 1);
847 }
848
849 fn move_to_start(&mut self) {
850 self.replay = Some(-1);
851 let (_, temporal_events, _) = self.replay_counts();
852 self.temporal_replay = (temporal_events > 0).then_some(-1);
853 self.playing = false;
854 self.follow = true;
855 }
856
857 fn apply_timeline_action(&mut self, action: timeline::TimelineAction) {
858 match action {
859 timeline::TimelineAction::Start => self.move_to_start(),
860 timeline::TimelineAction::Previous => self.step_back(),
861 timeline::TimelineAction::TogglePlayback => {
862 if self.replay.is_none() && self.temporal_replay.is_none() {
863 self.move_to_start();
864 }
865 self.playing = !self.playing;
866 self.last_play_step = Instant::now();
867 }
868 timeline::TimelineAction::Next => self.step_forward(),
869 timeline::TimelineAction::Live => self.rejoin_live(),
870 timeline::TimelineAction::Slower => self.slower_playback(),
871 timeline::TimelineAction::Faster => self.faster_playback(),
872 }
873 }
874
875 fn step_back(&mut self) {
876 let (steps, temporal_events, _) = self.replay_counts();
877 if temporal_events > 0 {
878 let current = self.temporal_replay.unwrap_or(temporal_events - 1);
879 self.temporal_replay = Some((current - 1).max(-1));
880 self.sync_step_to_temporal();
881 } else {
882 let current = self.replay.unwrap_or(steps - 1);
883 self.replay = Some((current - 1).max(-1));
884 }
885 self.playing = false;
886 }
887
888 fn step_forward(&mut self) {
889 let (steps, temporal_events, _) = self.replay_counts();
890 if temporal_events > 0 {
891 match self.temporal_replay {
892 Some(position) if position + 1 >= temporal_events => self.rejoin_live(),
893 Some(position) => {
894 self.temporal_replay = Some(position + 1);
895 self.sync_step_to_temporal();
896 }
897 None => {}
898 }
899 } else {
900 match self.replay {
901 Some(position) if position + 1 >= steps => self.rejoin_live(),
902 Some(position) => self.replay = Some(position + 1),
903 None => {}
904 }
905 }
906 self.playing = false;
907 }
908
909 fn select_inspector_tab(&mut self, tab: InspectorTab) {
910 self.inspector_scrolls[self.tab.index()] = self.inspector_scroll;
911 self.tab = tab;
912 self.inspector_scroll = self.inspector_scrolls[tab.index()];
913 }
914
915 fn page_info(&mut self, direction: i64) {
916 let Some(run_id) = self.selected_run.clone() else {
917 return;
918 };
919 let cursors = {
920 let Some(data) = self.provider.data(&run_id) else {
921 return;
922 };
923 let follow_up_len = data
924 .follow_up_queue
925 .and_then(|queue| queue.get("items"))
926 .and_then(Value::as_array)
927 .map_or(0, Vec::len);
928 let settings = next_page_cursor(
929 data.settings_start,
930 data.settings_total,
931 data.settings_scopes.len(),
932 direction,
933 );
934 let follow_ups = next_page_cursor(
935 data.follow_up_start,
936 data.follow_up_total,
937 follow_up_len,
938 direction,
939 );
940 let updates = next_page_cursor(
941 data.update_start,
942 data.update_total,
943 data.state.updates.as_ref().map_or(0, Vec::len),
944 direction,
945 );
946 (settings, follow_ups, updates)
947 };
948 if cursors.0.is_some() || cursors.1.is_some() || cursors.2.is_some() {
949 self.provider
950 .request_info_window(&run_id, cursors.0, cursors.1, cursors.2);
951 self.inspector_scroll = 0;
952 }
953 }
954
955 fn request_selected_artifacts(&mut self) {
956 let Some(run_id) = self.selected_run.clone() else {
957 return;
958 };
959 let replay = self.replay;
960 let paths = {
961 let Some(data) = self.provider.data(&run_id) else {
962 return;
963 };
964 let index = replay.unwrap_or(data.step_total as i64 - 1) - data.step_start as i64;
965 let Some(step) = usize::try_from(index)
966 .ok()
967 .and_then(|index| data.state.steps.get(index))
968 else {
969 return;
970 };
971 let mut paths = Vec::new();
972 collect_artifact_paths(&step.prompt, &mut paths);
973 collect_artifact_paths(&step.output, &mut paths);
974 paths.sort();
975 paths.dedup();
976 paths
977 };
978 self.provider.request_artifacts(&run_id, &paths);
979 }
980
981 fn request_conversation_artifacts(&mut self) {
982 let Some(run_id) = self.selected_run.clone() else {
983 return;
984 };
985 let paths = {
986 let Some(data) = self.provider.data(&run_id) else {
987 return;
988 };
989 let mut paths = Vec::new();
990 for value in data.session_events.iter().chain(data.session_entries) {
991 collect_artifact_paths(value, &mut paths);
992 }
993 paths.sort();
994 paths.dedup();
995 paths
996 };
997 self.provider.request_artifacts(&run_id, &paths);
998 }
999
1000 fn select_graph_node(&mut self, node_id: &str) {
1001 let Some(run_id) = self.selected_run.clone() else {
1002 return;
1003 };
1004 let replay = self.replay;
1005 let selected = {
1006 let Some(data) = self.provider.data(&run_id) else {
1007 return;
1008 };
1009 let upper = replay.unwrap_or(data.step_total as i64 - 1) - data.step_start as i64;
1010 data.state
1011 .steps
1012 .iter()
1013 .enumerate()
1014 .rev()
1015 .find(|(index, step)| *index as i64 <= upper && step.node_id == node_id)
1016 .map(|(index, step)| {
1017 let temporal = data
1018 .session_events
1019 .iter()
1020 .rposition(|event| {
1021 event.get("attemptId").and_then(Value::as_str)
1022 == Some(step.attempt_id.as_str())
1023 })
1024 .map(|index| data.session_event_start as i64 + index as i64);
1025 (data.step_start as i64 + index as i64, temporal)
1026 })
1027 };
1028 if let Some((index, temporal)) = selected {
1029 self.temporal_replay = temporal;
1030 if temporal.is_some() {
1031 self.sync_step_to_temporal();
1032 } else {
1033 self.replay = Some(index);
1034 }
1035 self.playing = false;
1036 self.follow = true;
1037 }
1038 }
1039
1040 fn select_run(&mut self, summaries: &[RunSummary], delta: i64) {
1041 if summaries.is_empty() {
1042 return;
1043 }
1044 let current = summaries
1045 .iter()
1046 .position(|summary| Some(&summary.run_id) == self.selected_run.as_ref())
1047 .unwrap_or(0) as i64;
1048 let next = (current + delta).clamp(0, summaries.len() as i64 - 1) as usize;
1049 self.select_run_id(summaries[next].run_id.clone());
1050 }
1051
1052 fn select_run_id(&mut self, run_id: String) {
1053 if self.selected_run.as_deref() == Some(&run_id) {
1054 return;
1055 }
1056 self.selected_run = Some(run_id);
1057 self.replay = None;
1058 self.temporal_replay = None;
1059 self.playing = false;
1060 self.inspector_scroll = 0;
1061 self.inspector_scrolls = [0; 4];
1062 self.inspector_expanded = false;
1063 self.trace_selected = 0;
1064 self.trace_payload_expanded = false;
1065 self.conversation_follow = true;
1066 self.conversation_selected = 0;
1067 self.conversation_payload_expanded = false;
1068 self.graph_offset = (0, 0);
1069 self.follow = true;
1070 }
1071
1072 fn resize_sidebar(&mut self, divider_column: u16) {
1073 self.sidebar_width = sidebar_width_for_drag(self.frame_rect, divider_column);
1074 self.sidebar_collapsed = false;
1075 self.sidebar_explicit = true;
1076 }
1077
1078 fn resize_inspector(&mut self, divider_row: u16) {
1079 self.inspector_height = Some(inspector_height_for_drag(self.main_rect, divider_row));
1080 }
1081
1082 fn persist_layout(&mut self) {
1083 if let Err(error) = theme::save_layout(
1084 &self.theme_config_path,
1085 self.sidebar_width,
1086 self.inspector_height,
1087 ) {
1088 self.theme_diagnostic = Some(sanitize_text(&format!("layout not saved: {error}")));
1089 }
1090 }
1091
1092 fn open_theme_picker(&mut self) {
1093 self.theme_picker = Some(theme_picker::ThemePicker::new(&self.palette));
1094 }
1095
1096 fn preview_selected_theme(&mut self) {
1097 let Some(name) = self
1098 .theme_picker
1099 .as_ref()
1100 .map(|picker| picker.selected_name().to_string())
1101 else {
1102 return;
1103 };
1104 let (palette, diagnostics) = theme::palette_with_config(&name, &self.theme_config);
1105 self.palette = palette;
1106 if let Some(picker) = self.theme_picker.as_mut() {
1107 picker.error = diagnostics.into_iter().next();
1108 }
1109 }
1110
1111 fn cancel_theme_picker(&mut self) {
1112 if let Some(picker) = self.theme_picker.take() {
1113 self.palette = picker.original_palette;
1114 }
1115 }
1116
1117 fn apply_theme_picker(&mut self) {
1118 let Some(name) = self
1119 .theme_picker
1120 .as_ref()
1121 .map(|picker| picker.selected_name().to_string())
1122 else {
1123 return;
1124 };
1125 match theme::save_theme(&self.theme_config_path, &name) {
1126 Ok(()) => {
1127 self.theme_config.name = Some(name);
1128 self.theme_config.auto_switch = false;
1129 self.theme_picker = None;
1130 self.theme_diagnostic = None;
1131 }
1132 Err(error) => {
1133 if let Some(picker) = self.theme_picker.as_mut() {
1134 picker.error = Some(sanitize_text(&format!("{error:#}")));
1135 }
1136 }
1137 }
1138 }
1139}
1140
1141fn handle_theme_picker_key(app: &mut App, key: KeyEvent) {
1142 match key.code {
1143 KeyCode::Up | KeyCode::Char('k') => {
1144 if let Some(picker) = app.theme_picker.as_mut() {
1145 picker.move_previous();
1146 }
1147 app.preview_selected_theme();
1148 }
1149 KeyCode::Down | KeyCode::Char('j') => {
1150 if let Some(picker) = app.theme_picker.as_mut() {
1151 picker.move_next();
1152 }
1153 app.preview_selected_theme();
1154 }
1155 KeyCode::Enter => app.apply_theme_picker(),
1156 KeyCode::Esc => app.cancel_theme_picker(),
1157 _ => {}
1158 }
1159}
1160
1161fn handle_key(app: &mut App, summaries: &[RunSummary], key: KeyEvent) {
1162 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
1163 app.quit = true;
1164 return;
1165 }
1166 if app.theme_picker.is_some() {
1167 handle_theme_picker_key(app, key);
1168 return;
1169 }
1170 match key.code {
1171 KeyCode::Char('q') => app.quit = true,
1172 KeyCode::Char(',') => app.open_theme_picker(),
1173 KeyCode::Char('b') if app.show_sidebar => {
1174 app.sidebar_collapsed = !app.sidebar_collapsed;
1175 app.sidebar_explicit = true;
1176 }
1177 KeyCode::Tab => {
1178 app.focus = match (app.focus, app.show_sidebar) {
1179 (Focus::Runs, _) => Focus::Graph,
1180 (Focus::Graph, _) => Focus::Inspector,
1181 (Focus::Inspector, true) => Focus::Runs,
1182 (Focus::Inspector, false) => Focus::Graph,
1183 };
1184 }
1185 KeyCode::Char('[') => app.step_back(),
1187 KeyCode::Char(']') => app.step_forward(),
1188 KeyCode::Char('{') => app.slower_playback(),
1189 KeyCode::Char('}') => app.faster_playback(),
1190 KeyCode::Char(' ') => app.apply_timeline_action(timeline::TimelineAction::TogglePlayback),
1191 KeyCode::Home | KeyCode::Char('g') => app.move_to_start(),
1192 KeyCode::End | KeyCode::Char('G') | KeyCode::Char('L') => app.rejoin_live(),
1193 KeyCode::Char('z') | KeyCode::Char('+') | KeyCode::Char('-') => {
1194 app.node_style = match app.node_style {
1195 GraphNodeStyle::Line => GraphNodeStyle::Box,
1196 GraphNodeStyle::Box => GraphNodeStyle::Line,
1197 };
1198 }
1199 KeyCode::Char('f') => app.follow = !app.follow,
1200 KeyCode::Char('t') => app.select_inspector_tab(app.tab.next()),
1201 KeyCode::Char('1') => app.select_inspector_tab(InspectorTab::Steps),
1202 KeyCode::Char('2') => app.select_inspector_tab(InspectorTab::Trace),
1203 KeyCode::Char('3') => app.select_inspector_tab(InspectorTab::Conversation),
1204 KeyCode::Char('4') => app.select_inspector_tab(InspectorTab::Info),
1205 _ => match app.focus {
1206 Focus::Runs => match key.code {
1207 KeyCode::Up | KeyCode::Char('k') => app.select_run(summaries, -1),
1208 KeyCode::Down | KeyCode::Char('j') => app.select_run(summaries, 1),
1209 _ => {}
1210 },
1211 Focus::Graph => {
1212 let (x, y) = app.graph_offset;
1213 match key.code {
1214 KeyCode::Up | KeyCode::Char('k') => {
1215 app.graph_offset = (x, y - 2);
1216 app.follow = false;
1217 }
1218 KeyCode::Down | KeyCode::Char('j') => {
1219 app.graph_offset = (x, y + 2);
1220 app.follow = false;
1221 }
1222 KeyCode::Left | KeyCode::Char('h') => {
1223 app.graph_offset = (x - 4, y);
1224 app.follow = false;
1225 }
1226 KeyCode::Right | KeyCode::Char('l') => {
1227 app.graph_offset = (x + 4, y);
1228 app.follow = false;
1229 }
1230 KeyCode::Char('0') => {
1231 app.graph_offset = (0, 0);
1232 app.follow = true;
1233 }
1234 _ => {}
1235 }
1236 }
1237 Focus::Inspector => match key.code {
1238 KeyCode::Up | KeyCode::Char('k') => match app.tab {
1239 InspectorTab::Steps => app.step_back(),
1240 InspectorTab::Trace => {
1241 app.trace_selected = app.trace_selected.saturating_sub(1);
1242 app.trace_payload_expanded = false;
1243 }
1244 InspectorTab::Conversation => {
1245 app.conversation_selected = app.conversation_selected.saturating_sub(1);
1246 app.conversation_payload_expanded = false;
1247 app.conversation_follow = false;
1248 }
1249 InspectorTab::Info => {
1250 app.inspector_scroll = app.inspector_scroll.saturating_sub(1)
1251 }
1252 },
1253 KeyCode::Down | KeyCode::Char('j') => match app.tab {
1254 InspectorTab::Steps => app.step_forward(),
1255 InspectorTab::Trace => {
1256 app.trace_selected = app.trace_selected.saturating_add(1);
1257 app.trace_payload_expanded = false;
1258 }
1259 InspectorTab::Conversation => {
1260 app.conversation_selected = app.conversation_selected.saturating_add(1);
1261 app.conversation_payload_expanded = false;
1262 app.conversation_follow = false;
1263 }
1264 InspectorTab::Info => app.inspector_scroll += 1,
1265 },
1266 KeyCode::Enter => match app.tab {
1267 InspectorTab::Steps => {
1268 app.inspector_expanded = !app.inspector_expanded;
1269 if app.inspector_expanded {
1270 app.request_selected_artifacts();
1271 }
1272 }
1273 InspectorTab::Trace => app.trace_payload_expanded = !app.trace_payload_expanded,
1274 InspectorTab::Conversation => {
1275 app.conversation_payload_expanded = !app.conversation_payload_expanded;
1276 if app.conversation_payload_expanded {
1277 app.request_conversation_artifacts();
1278 }
1279 }
1280 InspectorTab::Info => {}
1281 },
1282 KeyCode::Char('v') if app.tab == InspectorTab::Trace => {
1283 app.trace_scope = app.trace_scope.next();
1284 app.trace_selected = 0;
1285 app.trace_payload_expanded = false;
1286 app.inspector_scroll = 0;
1287 }
1288 KeyCode::Char('<') if app.tab == InspectorTab::Info => app.page_info(-1),
1289 KeyCode::Char('>') if app.tab == InspectorTab::Info => app.page_info(1),
1290 KeyCode::PageUp => app.inspector_scroll = app.inspector_scroll.saturating_sub(10),
1291 KeyCode::PageDown => app.inspector_scroll += 10,
1292 _ => {}
1293 },
1294 },
1295 }
1296}
1297
1298fn contains(rect: Rect, x: u16, y: u16) -> bool {
1299 x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
1300}
1301
1302fn inspector_tab_label(tab: InspectorTab, available_width: u16) -> String {
1303 if available_width >= 48 {
1304 controls::button_label(tab.symbol(), tab.title())
1305 } else if available_width >= 39 {
1306 let title = if tab == InspectorTab::Conversation {
1307 "Chat"
1308 } else {
1309 tab.title()
1310 };
1311 controls::button_label(tab.symbol(), title)
1312 } else {
1313 format!("[{}]", tab.symbol())
1314 }
1315}
1316
1317fn inspector_tab_layout(area: Rect) -> Vec<InspectorTabHit> {
1318 let mut hits = Vec::with_capacity(InspectorTab::ALL.len());
1319 let mut x = area.x;
1320 let right = area.right();
1321 for tab in InspectorTab::ALL {
1322 let label = inspector_tab_label(tab, area.width);
1323 let width = label.chars().count() as u16;
1324 if width == 0 || x.saturating_add(width) > right {
1325 break;
1326 }
1327 hits.push(InspectorTabHit {
1328 rect: Rect::new(x, area.y, width, area.height.min(1)),
1329 tab,
1330 });
1331 x = x.saturating_add(width).saturating_add(1);
1332 }
1333 hits
1334}
1335
1336fn render_inspector_tabs(
1337 frame: &mut Frame,
1338 area: Rect,
1339 selected: InspectorTab,
1340 palette: &Palette,
1341) -> Vec<InspectorTabHit> {
1342 frame.render_widget(
1343 Paragraph::new("").style(Style::default().bg(palette.panel_bg)),
1344 area,
1345 );
1346 let hits = inspector_tab_layout(area);
1347 for hit in &hits {
1348 let label = inspector_tab_label(hit.tab, area.width);
1349 frame.render_widget(
1350 Paragraph::new(label).style(controls::button_style(palette, hit.tab == selected)),
1351 hit.rect,
1352 );
1353 }
1354 hits
1355}
1356
1357fn sidebar_width_for_drag(frame: Rect, divider_column: u16) -> u16 {
1358 let requested = divider_column.saturating_sub(frame.x).saturating_add(1);
1359 let max_width = frame.width.saturating_sub(MIN_MAIN_WIDTH);
1360 requested.clamp(MIN_SIDEBAR_WIDTH, max_width.max(MIN_SIDEBAR_WIDTH))
1361}
1362
1363fn inspector_height_for_drag(main: Rect, divider_row: u16) -> u16 {
1364 let requested = main.bottom().saturating_sub(divider_row);
1365 let max_height = main.height.saturating_sub(MIN_GRAPH_HEIGHT);
1366 requested.clamp(MIN_INSPECTOR_HEIGHT.min(max_height), max_height)
1367}
1368
1369fn resolved_inspector_height(total: u16, requested: Option<u16>) -> u16 {
1370 let available = total.saturating_sub(MIN_GRAPH_HEIGHT);
1371 if available == 0 {
1372 return 0;
1373 }
1374 let default = total.saturating_mul(40) / 100;
1375 requested
1376 .unwrap_or(default)
1377 .clamp(MIN_INSPECTOR_HEIGHT.min(available), available)
1378}
1379
1380fn clamp_camera_axis(origin: i64, content: usize, viewport: usize) -> i64 {
1381 if viewport == 0 {
1382 return 0;
1383 }
1384 let half = viewport as i64 / 2;
1385 origin.clamp(-half, content as i64 - half)
1386}
1387
1388fn centered_camera(
1389 node: Option<&NodeBounds>,
1390 content: (usize, usize),
1391 viewport: (usize, usize),
1392) -> (i64, i64) {
1393 let (center_x, center_y) = node
1394 .map_or((content.0 as i64 / 2, content.1 as i64 / 2), |bounds| {
1395 (bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
1396 });
1397 (
1398 center_x - viewport.0 as i64 / 2,
1399 center_y - viewport.1 as i64 / 2,
1400 )
1401}
1402
1403fn on_sidebar_divider(app: &App, column: u16, row: u16) -> bool {
1404 app.show_sidebar
1405 && app.runs_rect.width > 0
1406 && column == app.runs_rect.x + app.runs_rect.width - 1
1407 && row >= app.runs_rect.y
1408 && row < app.runs_rect.y + app.runs_rect.height
1409}
1410
1411fn on_inspector_divider(app: &App, column: u16, row: u16) -> bool {
1412 let on_boundary = row == app.inspector_rect.y
1413 || (app.graph_rect.height > 0
1414 && row == app.graph_rect.y + app.graph_rect.height.saturating_sub(1));
1415 on_boundary && column >= app.main_rect.x && column < app.main_rect.x + app.main_rect.width
1416}
1417
1418fn handle_mouse(app: &mut App, summaries: &[RunSummary], mouse: MouseEvent) {
1419 if app.theme_picker.is_some() {
1420 handle_theme_picker_mouse(app, mouse);
1421 return;
1422 }
1423 if app.dragging.is_none()
1424 && matches!(
1425 mouse.kind,
1426 MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left)
1427 )
1428 && contains(app.timeline.track, mouse.column, mouse.row)
1429 {
1430 let (steps, temporal_events, _) = app.replay_counts();
1431 let item_count = if temporal_events > 0 {
1432 temporal_events
1433 } else {
1434 steps
1435 } as usize;
1436 let column = mouse.column.saturating_sub(app.timeline.track.x) as usize;
1437 let position =
1438 timeline::position_from_column(item_count, column, app.timeline.track.width as usize);
1439 if position.is_none() {
1440 app.rejoin_live();
1441 } else if temporal_events > 0 {
1442 app.temporal_replay = position;
1443 app.sync_step_to_temporal();
1444 app.playing = false;
1445 } else {
1446 app.replay = position;
1447 app.playing = false;
1448 }
1449 return;
1450 }
1451 if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
1452 if let Some(action) = app
1453 .timeline
1454 .hits
1455 .iter()
1456 .find(|hit| contains(hit.rect, mouse.column, mouse.row))
1457 .map(|hit| hit.action)
1458 {
1459 app.apply_timeline_action(action);
1460 return;
1461 }
1462 if let Some(tab) = app
1463 .inspector_tab_hits
1464 .iter()
1465 .find(|hit| contains(hit.rect, mouse.column, mouse.row))
1466 .map(|hit| hit.tab)
1467 {
1468 app.focus = Focus::Inspector;
1469 app.select_inspector_tab(tab);
1470 return;
1471 }
1472 }
1473 match mouse.kind {
1474 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1475 let delta: i64 = if mouse.kind == MouseEventKind::ScrollUp {
1476 -3
1477 } else {
1478 3
1479 };
1480 if contains(app.graph_rect, mouse.column, mouse.row) {
1481 let (x, y) = app.graph_offset;
1482 app.graph_offset = (x, y + delta);
1483 app.follow = false;
1484 } else if contains(app.runs_rect, mouse.column, mouse.row) {
1485 app.select_run(summaries, delta.signum());
1486 } else if contains(app.inspector_rect, mouse.column, mouse.row) {
1487 app.inspector_scroll = (app.inspector_scroll as i64 + delta).max(0) as usize;
1488 if app.tab == InspectorTab::Conversation {
1489 app.conversation_follow = false;
1490 }
1491 }
1492 }
1493 MouseEventKind::Down(MouseButton::Left) => {
1494 if on_sidebar_divider(app, mouse.column, mouse.row) {
1495 app.dragging = Some(DragTarget::Sidebar);
1496 app.resize_sidebar(mouse.column);
1497 } else if on_inspector_divider(app, mouse.column, mouse.row) {
1498 app.dragging = Some(DragTarget::Inspector);
1499 app.resize_inspector(mouse.row);
1500 } else if contains(app.graph_rect, mouse.column, mouse.row) {
1501 app.focus = Focus::Graph;
1502 app.dragging = Some(DragTarget::Graph {
1503 start_x: mouse.column,
1504 start_y: mouse.row,
1505 origin_x: app.graph_offset.0,
1506 origin_y: app.graph_offset.1,
1507 });
1508 } else if contains(app.runs_rect, mouse.column, mouse.row) {
1509 app.focus = Focus::Runs;
1510 let row = mouse.row.saturating_sub(app.runs_rect.y + 1) as usize;
1512 let index = app.runs_scroll + row;
1513 if index < summaries.len() {
1514 app.select_run_id(summaries[index].run_id.clone());
1515 }
1516 } else if contains(app.inspector_rect, mouse.column, mouse.row) {
1517 app.focus = Focus::Inspector;
1518 }
1519 }
1520 MouseEventKind::Drag(MouseButton::Left) => match app.dragging {
1521 Some(DragTarget::Graph {
1522 start_x,
1523 start_y,
1524 origin_x,
1525 origin_y,
1526 }) => {
1527 let dx = start_x as i64 - mouse.column as i64;
1528 let dy = start_y as i64 - mouse.row as i64;
1529 app.graph_offset = (origin_x + dx, origin_y + dy);
1530 app.follow = false;
1531 }
1532 Some(DragTarget::Sidebar) => app.resize_sidebar(mouse.column),
1533 Some(DragTarget::Inspector) => app.resize_inspector(mouse.row),
1534 None => {}
1535 },
1536 MouseEventKind::Up(MouseButton::Left) => match app.dragging.take() {
1537 Some(DragTarget::Graph {
1538 start_x, start_y, ..
1539 }) => {
1540 let moved = start_x.abs_diff(mouse.column) + start_y.abs_diff(mouse.row);
1541 if moved <= 1 && contains(app.graph_rect, mouse.column, mouse.row) {
1542 let canvas_x = i64::from(
1543 mouse
1544 .column
1545 .saturating_sub(app.graph_rect.x.saturating_add(1)),
1546 ) + app.graph_offset.0;
1547 let canvas_y =
1548 i64::from(mouse.row.saturating_sub(app.graph_rect.y.saturating_add(1)))
1549 + app.graph_offset.1;
1550 let node_id = app
1551 .graph_nodes
1552 .iter()
1553 .find(|node| {
1554 canvas_x >= node.x
1555 && canvas_x < node.x + node.width
1556 && canvas_y >= node.y
1557 && canvas_y < node.y + node.height
1558 })
1559 .map(|node| node.node_id.clone());
1560 if let Some(node_id) = node_id {
1561 app.select_graph_node(&node_id);
1562 }
1563 }
1564 }
1565 Some(DragTarget::Sidebar | DragTarget::Inspector) => app.persist_layout(),
1566 None => {}
1567 },
1568 _ => {}
1569 }
1570}
1571
1572fn handle_theme_picker_mouse(app: &mut App, mouse: MouseEvent) {
1573 if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
1574 return;
1575 }
1576 let popup = theme_picker::popup_rect(app.frame_rect);
1577 if !contains(popup, mouse.column, mouse.row) {
1578 return;
1579 }
1580 let inner_y = popup.y.saturating_add(1);
1581 let footer_height = if app
1582 .theme_picker
1583 .as_ref()
1584 .is_some_and(|picker| picker.error.is_some())
1585 {
1586 3
1587 } else {
1588 2
1589 };
1590 let list_height = popup.height.saturating_sub(2).saturating_sub(footer_height);
1591 if mouse.row >= inner_y && mouse.row < inner_y.saturating_add(list_height) {
1592 let index = mouse.row.saturating_sub(inner_y) as usize;
1593 if index < theme::THEME_NAMES.len() {
1594 if let Some(picker) = app.theme_picker.as_mut() {
1595 picker.selected = index;
1596 picker.error = None;
1597 }
1598 app.preview_selected_theme();
1599 }
1600 } else if let Some(action) =
1601 theme_picker::action_at(app.frame_rect, footer_height == 3, mouse.column, mouse.row)
1602 {
1603 match action {
1604 theme_picker::ThemeAction::Apply => app.apply_theme_picker(),
1605 theme_picker::ThemeAction::Cancel => app.cancel_theme_picker(),
1606 }
1607 }
1608}
1609
1610fn status_style(status: RunStatus, palette: &Palette) -> Style {
1611 let color = match status {
1612 RunStatus::Queued => palette.muted,
1613 RunStatus::Running => palette.running,
1614 RunStatus::Waiting => palette.warning,
1615 RunStatus::Completed => palette.success,
1616 RunStatus::Failed => palette.error,
1617 RunStatus::TimedOut => palette.timed_out,
1618 RunStatus::Cancelled => palette.cancelled,
1619 };
1620 Style::default().fg(color)
1621}
1622
1623fn status_glyph(status: RunStatus) -> &'static str {
1624 match status {
1625 RunStatus::Queued => "·",
1626 RunStatus::Running => "◐",
1627 RunStatus::Waiting => "⏸",
1628 RunStatus::Completed => "✓",
1629 RunStatus::Failed => "✗",
1630 RunStatus::TimedOut => "×",
1631 RunStatus::Cancelled => "~",
1632 }
1633}
1634
1635fn now_ms() -> i64 {
1636 chrono::Utc::now().timestamp_millis()
1637}
1638
1639fn draw(frame: &mut Frame, app: &mut App, summaries: &[RunSummary]) {
1640 let area = frame.area();
1641 app.frame_rect = area;
1642 app.inspector_tab_hits.clear();
1643 let palette = app.palette.clone();
1644 frame.render_widget(
1645 Block::default().style(Style::default().fg(palette.text).bg(palette.app_bg)),
1646 area,
1647 );
1648 let transport_height = if area.height >= 18 && area.width >= 60 {
1649 2
1650 } else {
1651 1
1652 };
1653 let vertical = Layout::default()
1654 .direction(Direction::Vertical)
1655 .constraints([Constraint::Min(4), Constraint::Length(transport_height)])
1656 .split(area);
1657 let body = vertical[0];
1658 let transport = vertical[1];
1659
1660 let sidebar_collapsed = app.sidebar_collapsed || (!app.sidebar_explicit && area.width < 100);
1661 let (runs_area, main_area) = if app.show_sidebar {
1662 let max_sidebar = body.width.saturating_sub(MIN_MAIN_WIDTH);
1663 let sidebar_width = if sidebar_collapsed {
1664 8
1665 } else {
1666 app.sidebar_width
1667 .clamp(MIN_SIDEBAR_WIDTH, max_sidebar.max(MIN_SIDEBAR_WIDTH))
1668 };
1669 let columns = Layout::default()
1670 .direction(Direction::Horizontal)
1671 .constraints([
1672 Constraint::Length(sidebar_width),
1673 Constraint::Min(MIN_MAIN_WIDTH),
1674 ])
1675 .split(body);
1676 (Some(columns[0]), columns[1])
1677 } else {
1678 (None, body)
1679 };
1680
1681 let inspector_height = resolved_inspector_height(main_area.height, app.inspector_height);
1682 let rows = Layout::default()
1683 .direction(Direction::Vertical)
1684 .constraints([
1685 Constraint::Min(MIN_GRAPH_HEIGHT),
1686 Constraint::Length(inspector_height),
1687 ])
1688 .split(main_area);
1689 app.main_rect = main_area;
1690 app.graph_rect = rows[0];
1691 app.inspector_rect = rows[1];
1692 app.runs_rect = runs_area.unwrap_or_default();
1693
1694 if let Some(runs_area) = runs_area {
1695 draw_runs(frame, app, summaries, runs_area, sidebar_collapsed);
1696 }
1697
1698 let Some(run_id) = app.selected_run.clone() else {
1699 let message = match &app.provider {
1702 Provider::Remote(remote) if !remote.connected() => {
1703 let detail = remote
1704 .error()
1705 .map(|error| format!(": {}", sanitize_text(&error)))
1706 .unwrap_or_default();
1707 format!("{}…{detail}", remote.status_label())
1708 }
1709 Provider::Remote(_) => "No runs found.".to_string(),
1710 _ => "No runs found.".to_string(),
1711 };
1712 frame.render_widget(
1713 Paragraph::new(message)
1714 .style(Style::default().fg(palette.text).bg(palette.panel_bg))
1715 .block(
1716 Block::default()
1717 .borders(Borders::ALL)
1718 .title(" piw ")
1719 .style(Style::default().bg(palette.panel_bg))
1720 .border_style(pane_border(&palette, false)),
1721 ),
1722 main_area,
1723 );
1724 app.timeline = draw_transport(
1725 frame,
1726 transport,
1727 None,
1728 TransportOptions {
1729 temporal_replay: None,
1730 playing: app.playing,
1731 speed: timeline::PLAYBACK_SPEEDS[app.playback_speed_index],
1732 diagnostic: app.theme_diagnostic.as_deref(),
1733 },
1734 &palette,
1735 );
1736 if let Some(picker) = &app.theme_picker {
1737 theme_picker::render(frame, area, picker, &palette);
1738 }
1739 return;
1740 };
1741 let replay = app.replay;
1742 let temporal_replay = app.temporal_replay;
1743 let node_style = app.node_style;
1744 let follow = app.follow;
1745 let graph_rect = app.graph_rect;
1746 let inspector_rect = app.inspector_rect;
1747 let tab = app.tab;
1748 let inspector_scroll = app.inspector_scroll;
1749 let inspector_expanded = app.inspector_expanded;
1750 let trace_scope = app.trace_scope;
1751 let trace_selected = app.trace_selected;
1752 let trace_payload_expanded = app.trace_payload_expanded;
1753 let conversation_follow = app.conversation_follow;
1754 let conversation_selected = if conversation_follow {
1755 usize::MAX
1756 } else {
1757 app.conversation_selected
1758 };
1759 let conversation_payload_expanded = app.conversation_payload_expanded;
1760 let focus = app.focus;
1761 let playing = app.playing;
1762 let remote_status = match &app.provider {
1765 Provider::Remote(remote) if !remote.connected() => Some(remote.status_label()),
1766 _ => None,
1767 };
1768 let (local_load_error, local_stale) = match &app.provider {
1769 Provider::Local { source, .. } => (
1770 source.load_error(&run_id).map(str::to_string),
1771 source.is_stale(&run_id),
1772 ),
1773 Provider::Remote(_) => (None, false),
1774 };
1775
1776 let Some(data) = app.provider.data(&run_id) else {
1777 frame.render_widget(
1778 Paragraph::new(local_load_error.as_deref().map_or("Loading run…", |_| {
1779 "Run unavailable. The run list is still usable."
1780 }))
1781 .style(Style::default().fg(palette.text).bg(palette.panel_bg))
1782 .block(
1783 Block::default()
1784 .borders(Borders::ALL)
1785 .title(" piw ")
1786 .style(Style::default().bg(palette.panel_bg))
1787 .border_style(pane_border(&palette, false)),
1788 ),
1789 main_area,
1790 );
1791 app.timeline = draw_transport(
1792 frame,
1793 transport,
1794 None,
1795 TransportOptions {
1796 temporal_replay: None,
1797 playing: app.playing,
1798 speed: timeline::PLAYBACK_SPEEDS[app.playback_speed_index],
1799 diagnostic: app.theme_diagnostic.as_deref(),
1800 },
1801 &palette,
1802 );
1803 if let Some(picker) = &app.theme_picker {
1804 theme_picker::render(frame, area, picker, &palette);
1805 }
1806 return;
1807 };
1808
1809 let steps = &data.state.steps;
1810 let selected_index = replay.unwrap_or(data.step_total as i64 - 1);
1811 let bounded_index = (selected_index - data.step_start as i64)
1812 .max(-1)
1813 .min(steps.len() as i64 - 1);
1814 let at_latest = replay.is_none() && temporal_replay.is_none();
1815 let through_event_seq = temporal_replay.map(|position| {
1816 let local = position - data.session_event_start as i64;
1817 temporal_through_seq(data.session_events, local)
1818 });
1819 let visible_steps = &steps[0..(bounded_index + 1).max(0) as usize];
1820 let selected_step = if bounded_index >= 0 {
1821 steps.get(bounded_index as usize)
1822 } else {
1823 None
1824 };
1825
1826 let view = GraphView {
1828 state: data.state,
1829 snapshot: data.snapshot,
1830 graph_steps: Some(data.graph_steps),
1831 taken_transitions: Some(data.taken_transitions),
1832 };
1833 let render_index = if at_latest {
1834 steps.len() as i64 - 1
1835 } else {
1836 bounded_index
1837 };
1838 let temporal_node_id = temporal_replay.and_then(|position| {
1839 usize::try_from(position - data.session_event_start as i64)
1840 .ok()
1841 .and_then(|index| data.session_events.get(index))
1842 .and_then(|event| event.get("nodeId"))
1843 .and_then(Value::as_str)
1844 });
1845 let followed_node_id = if at_latest {
1846 data.state
1847 .current_node
1848 .as_deref()
1849 .or(data.state.waiting_on.as_deref())
1850 .or_else(|| selected_step.map(|step| step.node_id.as_str()))
1851 } else {
1852 temporal_node_id.or_else(|| selected_step.map(|step| step.node_id.as_str()))
1853 };
1854 let rendered_at = now_ms();
1855 let graph_projection_ready = selected_index < 0 || data.graph_cursor == selected_index as u64;
1856 let cache_key = GraphCacheKey {
1857 run_id: run_id.clone(),
1858 graph_revision: data.graph_revision,
1859 replay_position: selected_index,
1860 graph_cursor: data.graph_cursor,
1861 at_latest,
1862 node_style,
1863 elapsed_second: if at_latest && data.state.current_node.is_some() {
1864 rendered_at / 1_000
1865 } else {
1866 0
1867 },
1868 };
1869 if app
1870 .graph_cache
1871 .as_ref()
1872 .is_none_or(|cache| cache.key != cache_key)
1873 {
1874 let rendered = graph_projection_ready
1875 .then(|| {
1876 data.graph_layout.map_or_else(
1877 || render_graph(&view, render_index, at_latest, rendered_at, node_style),
1878 |layout| {
1879 render_graph_with_layout(
1880 &view,
1881 layout,
1882 render_index,
1883 at_latest,
1884 rendered_at,
1885 node_style,
1886 )
1887 },
1888 )
1889 })
1890 .flatten();
1891 app.graph_cache = Some(GraphCache {
1892 key: cache_key,
1893 rendered,
1894 });
1895 }
1896 let rendered_graph = app
1897 .graph_cache
1898 .as_ref()
1899 .and_then(|cache| cache.rendered.as_ref());
1900 app.graph_nodes = rendered_graph
1901 .map(|rendered| rendered.node_bounds.clone())
1902 .unwrap_or_default();
1903 let inner_width = graph_rect.width.saturating_sub(2) as usize;
1904 let inner_height = graph_rect.height.saturating_sub(2) as usize;
1905 let content_size = rendered_graph
1906 .map(|rendered| rendered.canvas.size())
1907 .unwrap_or_default();
1908 let mut offset = app.graph_offset;
1909 if follow {
1910 let focused = followed_node_id
1911 .and_then(|node_id| app.graph_nodes.iter().find(|node| node.node_id == node_id));
1912 offset = centered_camera(focused, content_size, (inner_width, inner_height));
1913 }
1914 offset.0 = clamp_camera_axis(offset.0, content_size.0, inner_width);
1915 offset.1 = clamp_camera_axis(offset.1, content_size.1, inner_height);
1916 app.graph_offset = offset;
1917 let rows_runs = rendered_graph
1918 .map(|rendered| {
1919 rendered
1920 .canvas
1921 .render_runs_window(offset.0, offset.1, inner_width, inner_height)
1922 })
1923 .unwrap_or_else(|| vec![Vec::new(); inner_height]);
1924 let lines: Vec<Line> = rows_runs
1925 .iter()
1926 .map(|runs| graph::viewport_line(runs, 0, inner_width, &palette))
1927 .collect();
1928 let capture = capture_integrity(&data);
1929 let mut graph_flags = Vec::new();
1930 if follow {
1931 graph_flags.push("FOLLOW");
1932 }
1933 if data.state.paused == Some(true) {
1934 graph_flags.push("PAUSED");
1935 }
1936 if capture.status == "failed" {
1937 graph_flags.push("CAPTURE FAILED");
1938 } else if capture.status == "invalid" {
1939 graph_flags.push("CAPTURE INVALID");
1940 }
1941 if !graph_projection_ready {
1942 graph_flags.push("LOADING REPLAY");
1943 }
1944 if local_stale {
1945 graph_flags.push("STALE DATA");
1946 }
1947 if let Some(status) = remote_status {
1948 graph_flags.push(match status {
1949 "connecting" => "CONNECTING",
1950 "reconnecting" => "RECONNECTING",
1951 _ => "DISCONNECTED",
1952 });
1953 }
1954 let suffix = if graph_flags.is_empty() {
1955 String::new()
1956 } else {
1957 format!(" — {}", graph_flags.join(" · "))
1958 };
1959 let graph_title = format!(
1960 " {} {}{} ",
1961 sanitize_text(&data.state.workflow_name),
1962 graph_position_label(at_latest, data.live),
1963 suffix
1964 );
1965 let graph_block = Block::default()
1966 .borders(Borders::ALL)
1967 .title(graph_title)
1968 .style(Style::default().bg(palette.canvas_bg))
1969 .border_style(pane_border(&palette, focus == Focus::Graph));
1970 frame.render_widget(
1971 Paragraph::new(lines)
1972 .style(Style::default().fg(palette.text).bg(palette.canvas_bg))
1973 .block(graph_block),
1974 graph_rect,
1975 );
1976
1977 let inspector_block = Block::default()
1980 .borders(Borders::ALL)
1981 .title(" Inspector · click a tab ")
1982 .style(Style::default().bg(palette.panel_bg))
1983 .border_style(pane_border(&palette, focus == Focus::Inspector));
1984 let inspector_inner = inspector_block.inner(inspector_rect);
1985 frame.render_widget(inspector_block, inspector_rect);
1986 let tabs_height = inspector_inner.height.min(1);
1987 let separator_height = u16::from(inspector_inner.height >= 3);
1988 let tabs_rect = Rect::new(
1989 inspector_inner.x,
1990 inspector_inner.y,
1991 inspector_inner.width,
1992 tabs_height,
1993 );
1994 let separator_rect = Rect::new(
1995 inspector_inner.x,
1996 inspector_inner.y.saturating_add(tabs_height),
1997 inspector_inner.width,
1998 separator_height,
1999 );
2000 let content_rect = Rect::new(
2001 inspector_inner.x,
2002 separator_rect.y.saturating_add(separator_height),
2003 inspector_inner.width,
2004 inspector_inner
2005 .height
2006 .saturating_sub(tabs_height)
2007 .saturating_sub(separator_height),
2008 );
2009 app.inspector_tab_hits = render_inspector_tabs(frame, tabs_rect, tab, &palette);
2010 if separator_height > 0 {
2011 frame.render_widget(
2012 Paragraph::new("─".repeat(separator_rect.width as usize))
2013 .style(Style::default().fg(palette.border).bg(palette.panel_bg)),
2014 separator_rect,
2015 );
2016 }
2017
2018 let inspector_lines = match tab {
2019 InspectorTab::Steps => steps_lines(
2020 &data,
2021 visible_steps,
2022 selected_step,
2023 bounded_index,
2024 inspector_expanded,
2025 content_rect.width as usize,
2026 &palette,
2027 ),
2028 InspectorTab::Trace => trace_lines(
2029 data.events,
2030 visible_steps,
2031 selected_step,
2032 trace_scope,
2033 trace_selected,
2034 trace_payload_expanded,
2035 content_rect.width as usize,
2036 &palette,
2037 ),
2038 InspectorTab::Conversation => conversation::conversation_lines(
2039 data.session_entries,
2040 data.session_events,
2041 visible_steps,
2042 selected_step,
2043 conversation::ConversationRenderOptions {
2044 at_latest_step: at_latest,
2045 through_event_seq,
2046 width: content_rect.width as usize,
2047 palette: &palette,
2048 run_dir: data.run_dir,
2049 remote_artifacts: &data.remote_artifacts,
2050 selected_entry: Some(conversation_selected),
2051 payload_expanded: conversation_payload_expanded,
2052 replay_checkpoint: data.session_replay_checkpoint,
2053 },
2054 ),
2055 InspectorTab::Info => info_lines(&data, &run_id, &palette),
2056 };
2057 let inspector_height = content_rect.height as usize;
2058 let max_scroll = inspector_lines.len().saturating_sub(inspector_height);
2059 let scroll =
2060 if (tab == InspectorTab::Trace && at_latest && trace_scope == TraceScope::LoadedPage)
2061 || (tab == InspectorTab::Conversation && at_latest && conversation_follow)
2062 {
2063 max_scroll
2064 } else {
2065 inspector_scroll.min(max_scroll)
2066 };
2067 app.inspector_scroll = scroll;
2068 app.inspector_scrolls[tab.index()] = scroll;
2069 let shown: Vec<Line> = inspector_lines
2070 .into_iter()
2071 .skip(scroll)
2072 .take(inspector_height)
2073 .collect();
2074 frame.render_widget(
2075 Paragraph::new(shown).style(Style::default().fg(palette.text).bg(palette.panel_bg)),
2076 content_rect,
2077 );
2078
2079 let capture_diagnostic = matches!(capture.status, "failed" | "invalid").then(|| {
2080 capture
2081 .diagnostics
2082 .first()
2083 .cloned()
2084 .unwrap_or_else(|| format!("session capture {}", capture.status))
2085 });
2086 app.timeline = draw_transport(
2087 frame,
2088 transport,
2089 Some((&data, selected_index, at_latest)),
2090 TransportOptions {
2091 temporal_replay,
2092 playing,
2093 speed: timeline::PLAYBACK_SPEEDS[app.playback_speed_index],
2094 diagnostic: app
2095 .theme_diagnostic
2096 .as_deref()
2097 .or(capture_diagnostic.as_deref()),
2098 },
2099 &palette,
2100 );
2101 if let Some(picker) = &app.theme_picker {
2102 theme_picker::render(frame, area, picker, &palette);
2103 }
2104}
2105
2106fn temporal_delay_from_page(
2107 events: &[Value],
2108 page_start: u64,
2109 current: i64,
2110 speed: u32,
2111) -> TemporalDelay {
2112 let Ok(next_cursor) = u64::try_from(current + 1) else {
2113 return TemporalDelay::Invalid;
2114 };
2115 let Some(next_index) = next_cursor.checked_sub(page_start) else {
2116 return TemporalDelay::Pending(next_cursor);
2117 };
2118 let Some(next_at) = events
2119 .get(next_index as usize)
2120 .and_then(|event| event.get("at"))
2121 .and_then(Value::as_str)
2122 .and_then(parse_timestamp_ms)
2123 else {
2124 return if next_index as usize >= events.len() {
2125 TemporalDelay::Pending(next_cursor)
2126 } else {
2127 TemporalDelay::Invalid
2128 };
2129 };
2130 if current < 0 {
2131 return TemporalDelay::Ready(Duration::ZERO);
2132 }
2133 let Ok(current_cursor) = u64::try_from(current) else {
2134 return TemporalDelay::Invalid;
2135 };
2136 let Some(current_index) = current_cursor.checked_sub(page_start) else {
2137 return TemporalDelay::Pending(current_cursor);
2138 };
2139 let Some(current_at) = events
2140 .get(current_index as usize)
2141 .and_then(|event| event.get("at"))
2142 .and_then(Value::as_str)
2143 .and_then(parse_timestamp_ms)
2144 else {
2145 return if current_index as usize >= events.len() {
2146 TemporalDelay::Pending(current_cursor)
2147 } else {
2148 TemporalDelay::Invalid
2149 };
2150 };
2151 let scaled = (next_at - current_at).max(0) as u64 / u64::from(speed.max(1));
2152 TemporalDelay::Ready(Duration::from_millis(scaled.max(1)))
2153}
2154
2155fn next_page_cursor(start: u64, total: u64, length: usize, direction: i64) -> Option<u64> {
2156 if total == 0 || length == 0 {
2157 return None;
2158 }
2159 if direction > 0 && start.saturating_add(length as u64) < total {
2160 return Some(start.saturating_add(length as u64));
2161 }
2162 if direction < 0 && start > 0 {
2163 return Some(start - 1);
2164 }
2165 Some(start.saturating_add(length as u64 / 2).min(total - 1))
2166}
2167
2168fn step_projection_contains(
2169 cursor: u64,
2170 graph_cursor: u64,
2171 page_start: u64,
2172 page_len: usize,
2173) -> bool {
2174 graph_cursor == cursor
2175 && cursor >= page_start
2176 && cursor < page_start.saturating_add(page_len as u64)
2177}
2178
2179fn temporal_through_seq(events: &[Value], position: i64) -> u64 {
2180 usize::try_from(position)
2181 .ok()
2182 .and_then(|index| events.get(index))
2183 .and_then(|event| event.get("seq"))
2184 .and_then(Value::as_u64)
2185 .unwrap_or(0)
2186}
2187
2188fn completed_step_at(steps: &[StepRecord], event_at: i64) -> i64 {
2189 steps
2190 .iter()
2191 .enumerate()
2192 .rfind(|(_, step)| {
2193 parse_timestamp_ms(&step.finished_at).is_some_and(|finished| finished <= event_at)
2194 })
2195 .map(|(index, _)| index as i64)
2196 .unwrap_or(-1)
2197}
2198
2199fn graph_position_label(at_latest: bool, live: bool) -> &'static str {
2200 match (at_latest, live) {
2201 (false, _) => "(replay)",
2202 (true, true) => "(live)",
2203 (true, false) => "(latest)",
2204 }
2205}
2206
2207fn pane_border(palette: &Palette, focused: bool) -> Style {
2208 Style::default().fg(if focused {
2209 palette.border_focused
2210 } else {
2211 palette.border
2212 })
2213}
2214
2215fn draw_runs(
2216 frame: &mut Frame,
2217 app: &mut App,
2218 summaries: &[RunSummary],
2219 area: Rect,
2220 collapsed: bool,
2221) {
2222 let palette = &app.palette;
2223 let height = area.height.saturating_sub(2) as usize;
2224 let selected = summaries
2225 .iter()
2226 .position(|summary| Some(&summary.run_id) == app.selected_run.as_ref())
2227 .unwrap_or(0);
2228 if selected < app.runs_scroll {
2229 app.runs_scroll = selected;
2230 } else if height > 0 && selected >= app.runs_scroll + height {
2231 app.runs_scroll = selected + 1 - height;
2232 }
2233 let lines: Vec<Line> = summaries
2234 .iter()
2235 .enumerate()
2236 .skip(app.runs_scroll)
2237 .take(height.max(1))
2238 .map(|(index, summary)| {
2239 let marker = if index == selected { "▶ " } else { " " };
2240 let name = summary
2241 .run_title
2242 .clone()
2243 .unwrap_or_else(|| summary.workflow_name.clone());
2244 let interrupted = if summary.possibly_interrupted {
2245 " ?"
2246 } else {
2247 ""
2248 };
2249 let end = summary
2250 .finished_at
2251 .as_deref()
2252 .and_then(parse_timestamp_ms)
2253 .unwrap_or_else(now_ms);
2254 let elapsed = parse_timestamp_ms(&summary.started_at)
2255 .map(|start| format!(" {}", format_duration((end - start).max(0))))
2256 .unwrap_or_default();
2257 let mut spans = if collapsed {
2258 let initial = sanitize_text(&name)
2259 .chars()
2260 .next()
2261 .unwrap_or('?')
2262 .to_string();
2263 vec![
2264 Span::raw(if index == selected { "▶" } else { " " }),
2265 Span::styled(
2266 status_glyph(summary.status),
2267 status_style(summary.status, palette),
2268 ),
2269 Span::raw(initial),
2270 Span::styled(
2271 if summary.possibly_interrupted {
2272 "?"
2273 } else {
2274 " "
2275 },
2276 Style::default().fg(palette.timed_out),
2277 ),
2278 ]
2279 } else {
2280 vec![
2281 Span::raw(marker.to_string()),
2282 Span::styled(
2283 format!("{} ", status_glyph(summary.status)),
2284 status_style(summary.status, palette),
2285 ),
2286 Span::raw(sanitize_text(&name)),
2287 Span::styled(elapsed, Style::default().fg(palette.muted)),
2288 Span::styled(
2289 interrupted.to_string(),
2290 Style::default().fg(palette.timed_out),
2291 ),
2292 ]
2293 };
2294 if index == selected {
2295 spans = spans
2296 .into_iter()
2297 .map(|span| {
2298 span.patch_style(
2299 Style::default()
2300 .bg(palette.selection_bg)
2301 .add_modifier(Modifier::BOLD),
2302 )
2303 })
2304 .collect();
2305 }
2306 Line::from(spans)
2307 })
2308 .collect();
2309 let block = Block::default()
2310 .borders(Borders::ALL)
2311 .title(if collapsed {
2312 " R ".to_string()
2313 } else {
2314 format!(" Runs ({}) ↔ ", summaries.len())
2315 })
2316 .style(Style::default().bg(palette.panel_bg))
2317 .border_style(pane_border(palette, app.focus == Focus::Runs));
2318 frame.render_widget(
2319 Paragraph::new(lines)
2320 .style(Style::default().fg(palette.text).bg(palette.panel_bg))
2321 .block(block),
2322 area,
2323 );
2324}
2325
2326fn outcome_glyph(outcome: NodeOutcome, palette: &Palette) -> (&'static str, Style) {
2327 match outcome {
2328 NodeOutcome::Ok => ("✓", Style::default().fg(palette.success)),
2329 NodeOutcome::Failed => ("✗", Style::default().fg(palette.error)),
2330 NodeOutcome::TimedOut => ("×", Style::default().fg(palette.timed_out)),
2331 NodeOutcome::Cancelled => ("~", Style::default().fg(palette.cancelled)),
2332 }
2333}
2334
2335fn step_duration(step: &StepRecord) -> String {
2336 let duration = parse_timestamp_ms(&step.finished_at).unwrap_or(0)
2337 - parse_timestamp_ms(&step.started_at).unwrap_or(0);
2338 format_duration(duration)
2339}
2340
2341const PREVIEW_ARTIFACT_MAX_BYTES: u64 = 64 * 1024;
2344const DETAIL_ARTIFACT_MAX_BYTES: u64 = 4 * 1024 * 1024;
2345
2346fn collect_artifact_paths(value: &Value, paths: &mut Vec<String>) {
2347 if let Some(artifact) = crate::state::types::as_artifact_ref(value) {
2348 paths.push(artifact.path);
2349 return;
2350 }
2351 if let Some(escaped) = crate::state::types::as_escaped(value) {
2352 if let Some(object) = escaped.as_object() {
2353 for item in object.values() {
2354 collect_artifact_paths(item, paths);
2355 }
2356 }
2357 return;
2358 }
2359 match value {
2360 Value::Array(items) => {
2361 for item in items {
2362 collect_artifact_paths(item, paths);
2363 }
2364 }
2365 Value::Object(object) => {
2366 for item in object.values() {
2367 collect_artifact_paths(item, paths);
2368 }
2369 }
2370 _ => {}
2371 }
2372}
2373
2374fn resolve_remote_artifacts(
2375 value: &Value,
2376 artifacts: &HashMap<String, std::result::Result<String, String>>,
2377) -> Value {
2378 if let Some(artifact) = crate::state::types::as_artifact_ref(value) {
2379 return match artifacts.get(&artifact.path) {
2380 Some(Ok(content)) => Value::String(content.clone()),
2381 Some(Err(error)) => Value::String(format!("«artifact error: {error}»")),
2382 None => with_artifact_placeholders(value),
2383 };
2384 }
2385 if let Some(escaped) = crate::state::types::as_escaped(value) {
2386 return match escaped.as_object() {
2387 Some(object) => Value::Object(
2388 object
2389 .iter()
2390 .map(|(key, item)| (key.clone(), resolve_remote_artifacts(item, artifacts)))
2391 .collect(),
2392 ),
2393 None => escaped.clone(),
2394 };
2395 }
2396 match value {
2397 Value::Array(items) => Value::Array(
2398 items
2399 .iter()
2400 .map(|item| resolve_remote_artifacts(item, artifacts))
2401 .collect(),
2402 ),
2403 Value::Object(object) => Value::Object(
2404 object
2405 .iter()
2406 .map(|(key, item)| (key.clone(), resolve_remote_artifacts(item, artifacts)))
2407 .collect(),
2408 ),
2409 scalar => scalar.clone(),
2410 }
2411}
2412
2413fn resolve_detail_value(
2414 value: &Value,
2415 run_dir: Option<&std::path::Path>,
2416 remote_artifacts: &HashMap<String, std::result::Result<String, String>>,
2417) -> Value {
2418 match run_dir {
2419 Some(dir) => crate::state::reader::resolve_artifacts(value, dir, DETAIL_ARTIFACT_MAX_BYTES),
2420 None => resolve_remote_artifacts(value, remote_artifacts),
2421 }
2422}
2423
2424fn preview_value(
2427 value: &Value,
2428 run_dir: Option<&std::path::Path>,
2429 remote_artifacts: &HashMap<String, std::result::Result<String, String>>,
2430) -> String {
2431 let decoded = match run_dir {
2432 Some(dir) => {
2433 crate::state::reader::resolve_artifacts(value, dir, PREVIEW_ARTIFACT_MAX_BYTES)
2434 }
2435 None => resolve_remote_artifacts(value, remote_artifacts),
2436 };
2437 let text = match decoded {
2438 Value::String(text) => text,
2439 Value::Null => return "—".to_string(),
2440 other => serde_json::to_string(&other).unwrap_or_default(),
2441 };
2442 let sanitized = sanitize_text(&text);
2443 let chars: Vec<char> = sanitized.chars().collect();
2444 if chars.len() > 200 {
2445 format!("{}…", chars[..200].iter().collect::<String>())
2446 } else {
2447 sanitized
2448 }
2449}
2450
2451fn push_detail_line(
2452 lines: &mut Vec<Line<'static>>,
2453 label: &str,
2454 value: &str,
2455 width: usize,
2456 palette: &Palette,
2457) {
2458 let label_width = 14usize.min(width.saturating_sub(1));
2459 let body_width = width.saturating_sub(label_width).max(20);
2460 let text = sanitize_text(value);
2461 let chars: Vec<char> = text.chars().collect();
2462 let chunks: Vec<String> = if chars.is_empty() {
2463 vec!["—".to_string()]
2464 } else {
2465 chars
2466 .chunks(body_width)
2467 .map(|chunk| chunk.iter().collect())
2468 .collect()
2469 };
2470 for (index, chunk) in chunks.into_iter().enumerate() {
2471 let label_text = if index == 0 {
2472 format!("{label:<label_width$}")
2473 } else {
2474 " ".repeat(label_width)
2475 };
2476 lines.push(Line::from(vec![
2477 Span::styled(label_text, Style::default().fg(palette.accent)),
2478 Span::styled(chunk, Style::default().fg(palette.text)),
2479 ]));
2480 }
2481}
2482
2483fn resolved_detail_value(
2484 value: &Value,
2485 run_dir: Option<&std::path::Path>,
2486 remote_artifacts: &HashMap<String, std::result::Result<String, String>>,
2487) -> Value {
2488 match run_dir {
2489 Some(dir) => crate::state::reader::resolve_artifacts(value, dir, DETAIL_ARTIFACT_MAX_BYTES),
2490 None => resolve_remote_artifacts(value, remote_artifacts),
2491 }
2492}
2493
2494fn push_value_lines(
2495 lines: &mut Vec<Line<'static>>,
2496 label: &str,
2497 value: &Value,
2498 run_dir: Option<&std::path::Path>,
2499 remote_artifacts: &HashMap<String, std::result::Result<String, String>>,
2500 width: usize,
2501 palette: &Palette,
2502) {
2503 let decoded = resolved_detail_value(value, run_dir, remote_artifacts);
2504 let rendered = match decoded {
2505 Value::String(text) => text,
2506 other => serde_json::to_string_pretty(&other).unwrap_or_else(|_| other.to_string()),
2507 };
2508 for (index, logical_line) in rendered.lines().enumerate() {
2509 push_detail_line(
2510 lines,
2511 if index == 0 { label } else { "" },
2512 logical_line,
2513 width,
2514 palette,
2515 );
2516 }
2517 if rendered.is_empty() {
2518 push_detail_line(lines, label, "—", width, palette);
2519 }
2520}
2521
2522fn push_human_decision_presentation(
2523 lines: &mut Vec<Line<'static>>,
2524 value: &Value,
2525 run_dir: Option<&std::path::Path>,
2526 remote_artifacts: &HashMap<String, std::result::Result<String, String>>,
2527 width: usize,
2528 palette: &Palette,
2529) -> bool {
2530 let decoded = resolved_detail_value(value, run_dir, remote_artifacts);
2531 if decoded.get("schema").and_then(Value::as_str)
2532 != Some("pi-workflows.human-decision-request.v1")
2533 {
2534 return false;
2535 }
2536 let Some(presentation) = decoded.get("presentation") else {
2537 push_detail_line(
2538 lines,
2539 "decision",
2540 "Invalid readable presentation",
2541 width,
2542 palette,
2543 );
2544 return true;
2545 };
2546 if let Some(title) = decoded.get("title").and_then(Value::as_str) {
2547 push_detail_line(lines, "decision", title, width, palette);
2548 }
2549 if let Some(summary) = presentation.get("summary").and_then(Value::as_str) {
2550 push_detail_line(lines, "summary", summary, width, palette);
2551 }
2552 if let Some(blocks) = presentation.get("blocks").and_then(Value::as_array) {
2553 for block in blocks {
2554 match block.get("kind").and_then(Value::as_str) {
2555 Some("section") => {
2556 if let Some(title) = block.get("title").and_then(Value::as_str) {
2557 push_detail_line(lines, "section", title, width, palette);
2558 }
2559 }
2560 Some("paragraph") => {
2561 if let Some(text) = block.get("text").and_then(Value::as_str) {
2562 push_detail_line(lines, "details", text, width, palette);
2563 }
2564 }
2565 Some("preformatted") => {
2566 if let Some(text) = block.get("text").and_then(Value::as_str) {
2567 for (index, logical_line) in text.lines().enumerate() {
2568 push_detail_line(
2569 lines,
2570 if index == 0 { "text" } else { "" },
2571 logical_line,
2572 width,
2573 palette,
2574 );
2575 }
2576 }
2577 }
2578 Some("bullets") => {
2579 if let Some(items) = block.get("items").and_then(Value::as_array) {
2580 for item in items.iter().filter_map(Value::as_str) {
2581 push_detail_line(lines, "", &format!("• {item}"), width, palette);
2582 }
2583 }
2584 }
2585 Some("fields") => {
2586 if let Some(items) = block.get("items").and_then(Value::as_array) {
2587 for item in items {
2588 if let (Some(label), Some(value)) = (
2589 item.get("label").and_then(Value::as_str),
2590 item.get("value").and_then(Value::as_str),
2591 ) {
2592 push_detail_line(lines, label, value, width, palette);
2593 }
2594 }
2595 }
2596 }
2597 _ => push_detail_line(
2598 lines,
2599 "decision",
2600 "Unsupported presentation block",
2601 width,
2602 palette,
2603 ),
2604 }
2605 }
2606 }
2607 if let Some(choices) = decoded.get("choices").and_then(Value::as_object) {
2608 for choice in choices.values() {
2609 if let Some(label) = choice.get("label").and_then(Value::as_str) {
2610 push_detail_line(lines, "choice", label, width, palette);
2611 }
2612 if let Some(prompt) = choice.pointer("/input/prompt").and_then(Value::as_str) {
2613 push_detail_line(lines, "input", prompt, width, palette);
2614 }
2615 }
2616 }
2617 if let Some(digest) = decoded.get("presentationDigest").and_then(Value::as_str) {
2618 push_detail_line(lines, "presentation", digest, width, palette);
2619 }
2620 if let Some(digest) = decoded.get("subjectDigest").and_then(Value::as_str) {
2621 push_detail_line(lines, "subject", digest, width, palette);
2622 }
2623 if let Some(revision) = decoded.get("revision").and_then(Value::as_u64) {
2624 push_detail_line(lines, "revision", &revision.to_string(), width, palette);
2625 }
2626 true
2627}
2628
2629fn steps_lines(
2630 data: &RunData,
2631 visible_steps: &[StepRecord],
2632 selected_step: Option<&StepRecord>,
2633 bounded_index: i64,
2634 expanded: bool,
2635 width: usize,
2636 palette: &Palette,
2637) -> Vec<Line<'static>> {
2638 let mut lines: Vec<Line<'static>> = Vec::new();
2639 for (index, step) in visible_steps.iter().enumerate() {
2642 let (glyph, style) = outcome_glyph(step.outcome, palette);
2643 let selected = bounded_index >= 0 && index == bounded_index as usize;
2644 let marker = if selected { "▶" } else { " " };
2645 let mut line = vec![
2646 Span::raw(format!("{marker} ")),
2647 Span::styled(glyph.to_string(), style),
2648 Span::raw(sanitize_text(&format!(
2649 " {} [{}] {}",
2650 step.node_id,
2651 step.node_type,
2652 step_duration(step)
2653 ))),
2654 ];
2655 if step.conversation.is_some() {
2656 line.push(Span::styled(
2657 " ◆".to_string(),
2658 Style::default().fg(palette.replay_focus),
2659 ));
2660 }
2661 if selected {
2662 line = line
2663 .into_iter()
2664 .map(|span| span.add_modifier(Modifier::BOLD))
2665 .collect();
2666 }
2667 lines.push(Line::from(line));
2668 }
2669 if let Some(step) = selected_step {
2670 lines.push(Line::from(""));
2671 lines.push(Line::from(Span::styled(
2672 sanitize_text(&format!(
2673 "── step {} ({}) ──",
2674 step.node_id, step.attempt_id
2675 )),
2676 Style::default().fg(palette.muted),
2677 )));
2678 lines.push(Line::from(Span::styled(
2679 if expanded {
2680 "expanded details (Enter to collapse)"
2681 } else {
2682 "summary (Enter to expand)"
2683 },
2684 Style::default().fg(palette.muted),
2685 )));
2686 if let Some(snapshot) = data.snapshot {
2687 for edge in &snapshot.edges {
2688 if let EdgeDef::Switch { from, switch } = edge {
2689 if from == &step.node_id {
2690 for (case, target) in &switch.cases {
2691 push_detail_line(
2692 &mut lines,
2693 "branch",
2694 &format!(
2695 "{} -> {}",
2696 sanitize_text(case),
2697 target.as_str().map(sanitize_text).unwrap_or_default()
2698 ),
2699 width,
2700 palette,
2701 );
2702 }
2703 }
2704 }
2705 }
2706 }
2707 if expanded {
2708 if !step.prompt.is_null() {
2709 push_value_lines(
2710 &mut lines,
2711 "prompt",
2712 &step.prompt,
2713 data.run_dir,
2714 &data.remote_artifacts,
2715 width,
2716 palette,
2717 );
2718 }
2719 if !push_human_decision_presentation(
2720 &mut lines,
2721 &step.output,
2722 data.run_dir,
2723 &data.remote_artifacts,
2724 width,
2725 palette,
2726 ) {
2727 push_value_lines(
2728 &mut lines,
2729 "output",
2730 &step.output,
2731 data.run_dir,
2732 &data.remote_artifacts,
2733 width,
2734 palette,
2735 );
2736 }
2737 if let Some(action) = &step.action {
2738 push_detail_line(
2739 &mut lines,
2740 "action type",
2741 &action.action_type,
2742 width,
2743 palette,
2744 );
2745 if let Some(command) = &action.command {
2746 push_detail_line(&mut lines, "command", command, width, palette);
2747 }
2748 if let Some(args) = &action.args {
2749 push_detail_line(
2750 &mut lines,
2751 "arguments",
2752 &serde_json::to_string(args).unwrap_or_default(),
2753 width,
2754 palette,
2755 );
2756 }
2757 if let Some(cwd) = &action.cwd {
2758 push_detail_line(&mut lines, "working dir", cwd, width, palette);
2759 }
2760 if let Some(exit_code) = &action.exit_code {
2761 push_detail_line(
2762 &mut lines,
2763 "exit code",
2764 &exit_code.to_string(),
2765 width,
2766 palette,
2767 );
2768 }
2769 if let Some(signal) = &action.signal {
2770 push_detail_line(&mut lines, "signal", &signal.to_string(), width, palette);
2771 }
2772 if let Some(duration) = action.duration_ms {
2773 push_detail_line(
2774 &mut lines,
2775 "action time",
2776 &format_duration(duration as i64),
2777 width,
2778 palette,
2779 );
2780 }
2781 }
2782 if let Some(scope_id) = &step.settings_scope_id {
2783 push_detail_line(&mut lines, "settings scope", scope_id, width, palette);
2784 push_detail_line(
2785 &mut lines,
2786 "settings change",
2787 &step.settings_change_number.unwrap_or(0).to_string(),
2788 width,
2789 palette,
2790 );
2791 if let Some(hash) = &step.settings_hash {
2792 push_detail_line(&mut lines, "settings hash", hash, width, palette);
2793 }
2794 }
2795 if let Some(error) = &step.error {
2796 push_detail_line(&mut lines, "error", error, width, palette);
2797 }
2798 push_detail_line(&mut lines, "started", &step.started_at, width, palette);
2799 push_detail_line(&mut lines, "finished", &step.finished_at, width, palette);
2800 } else {
2801 if !step.prompt.is_null() {
2802 lines.push(Line::from(vec![
2803 Span::styled("prompt: ", Style::default().fg(palette.accent)),
2804 Span::raw(preview_value(
2805 &step.prompt,
2806 data.run_dir,
2807 &data.remote_artifacts,
2808 )),
2809 ]));
2810 }
2811 lines.push(Line::from(vec![
2812 Span::styled("output: ", Style::default().fg(palette.accent)),
2813 Span::raw(preview_value(
2814 &step.output,
2815 data.run_dir,
2816 &data.remote_artifacts,
2817 )),
2818 ]));
2819 if let Some(action) = &step.action {
2820 let command = action.command.clone().unwrap_or_default();
2821 lines.push(Line::from(vec![
2822 Span::styled("action: ", Style::default().fg(palette.accent)),
2823 Span::raw(sanitize_text(&format!(
2824 "{} {}",
2825 action.action_type, command
2826 ))),
2827 ]));
2828 }
2829 if let Some(change) = step.settings_change_number {
2830 lines.push(Line::from(vec![
2831 Span::styled("settings: ", Style::default().fg(palette.accent)),
2832 Span::raw(change.to_string()),
2833 ]));
2834 }
2835 if let Some(error) = &step.error {
2836 lines.push(Line::from(vec![
2837 Span::styled("error: ", Style::default().fg(palette.error)),
2838 Span::raw(sanitize_text(error)),
2839 ]));
2840 }
2841 }
2842 }
2843 lines
2844}
2845
2846fn trace_events_for_scope<'a>(
2847 events: &'a [Value],
2848 visible_steps: &[StepRecord],
2849 selected_step: Option<&StepRecord>,
2850 scope: TraceScope,
2851) -> Vec<&'a Value> {
2852 match scope {
2853 TraceScope::SelectedAttempt => {
2854 let Some(attempt_id) = selected_step.map(|step| step.attempt_id.as_str()) else {
2855 return Vec::new();
2856 };
2857 events
2858 .iter()
2859 .filter(|event| event.get("attemptId").and_then(Value::as_str) == Some(attempt_id))
2860 .collect()
2861 }
2862 TraceScope::ReplayVisible => {
2863 let attempts: HashSet<&str> = visible_steps
2864 .iter()
2865 .map(|step| step.attempt_id.as_str())
2866 .collect();
2867 let cutoff = events
2868 .iter()
2869 .filter(|event| {
2870 event
2871 .get("attemptId")
2872 .and_then(Value::as_str)
2873 .is_some_and(|attempt| attempts.contains(attempt))
2874 })
2875 .filter_map(|event| event.get("seq").and_then(Value::as_u64))
2876 .max();
2877 cutoff.map_or_else(Vec::new, |cutoff| {
2878 events
2879 .iter()
2880 .filter(|event| event.get("seq").and_then(Value::as_u64).unwrap_or(0) <= cutoff)
2881 .collect()
2882 })
2883 }
2884 TraceScope::LoadedPage => events.iter().collect(),
2885 }
2886}
2887
2888#[allow(clippy::too_many_arguments)]
2889fn trace_lines(
2890 events: &[Value],
2891 visible_steps: &[StepRecord],
2892 selected_step: Option<&StepRecord>,
2893 scope: TraceScope,
2894 selected_index: usize,
2895 payload_expanded: bool,
2896 width: usize,
2897 palette: &Palette,
2898) -> Vec<Line<'static>> {
2899 let filtered = trace_events_for_scope(events, visible_steps, selected_step, scope);
2900 let selected_index = selected_index.min(filtered.len().saturating_sub(1));
2901 let mut lines = vec![Line::from(vec![
2902 Span::styled("scope: ", Style::default().fg(palette.accent)),
2903 Span::styled(scope.label(), Style::default().fg(palette.text)),
2904 Span::styled(
2905 " v: change scope Enter: payload",
2906 Style::default().fg(palette.muted),
2907 ),
2908 ])];
2909 for (index, event) in filtered.iter().enumerate() {
2910 let seq = event.get("seq").and_then(Value::as_u64).unwrap_or(0);
2911 let event_type = sanitize_text(event.get("type").and_then(Value::as_str).unwrap_or("?"));
2912 let node = event
2913 .get("nodeId")
2914 .and_then(Value::as_str)
2915 .map(|node| format!(" {}", sanitize_text(node)))
2916 .unwrap_or_default();
2917 let style = match event_type.as_str() {
2918 "node_failed" | "run_failed" => Style::default().fg(palette.error),
2919 "run_completed" => Style::default().fg(palette.success),
2920 "node_started" => Style::default().fg(palette.running),
2921 _ => Style::default().fg(palette.text),
2922 };
2923 let marker = if index == selected_index { "▶" } else { " " };
2924 lines.push(Line::from(vec![
2925 Span::styled(marker, Style::default().fg(palette.replay_focus)),
2926 Span::styled(format!("{seq:>5} "), Style::default().fg(palette.muted)),
2927 Span::styled(event_type, style),
2928 Span::styled(node, Style::default().fg(palette.subtext)),
2929 ]));
2930 if index == selected_index && payload_expanded {
2931 let payload = event.get("payload").unwrap_or(&Value::Null);
2932 let rendered =
2933 serde_json::to_string_pretty(payload).unwrap_or_else(|_| payload.to_string());
2934 for logical_line in rendered.lines() {
2935 push_detail_line(&mut lines, "", logical_line, width, palette);
2936 }
2937 }
2938 }
2939 if filtered.is_empty() {
2940 lines.push(Line::from(Span::styled(
2941 "No events in this scope.",
2942 Style::default().fg(palette.muted),
2943 )));
2944 }
2945 lines
2946}
2947
2948fn capture_integrity(data: &RunData) -> CaptureIntegrity {
2949 if data.session_entry_total != data.session_entries.len() as u64
2950 || data.session_event_total != data.session_events.len() as u64
2951 {
2952 return CaptureIntegrity {
2953 status: "paged",
2954 diagnostics: vec!["integrity applies to the complete durable capture".into()],
2955 };
2956 }
2957 let entries: Result<Vec<SessionEntryRecord>, _> = data
2958 .session_entries
2959 .iter()
2960 .cloned()
2961 .map(serde_json::from_value)
2962 .collect();
2963 let events: Result<Vec<SessionEventRecord>, _> = data
2964 .session_events
2965 .iter()
2966 .cloned()
2967 .map(serde_json::from_value)
2968 .collect();
2969 let capture: Result<Option<SessionCapture>, _> = data
2970 .session_capture
2971 .cloned()
2972 .map(serde_json::from_value)
2973 .transpose();
2974 let (Ok(entries), Ok(events), Ok(capture)) = (entries, events, capture) else {
2975 return CaptureIntegrity {
2976 status: "invalid",
2977 diagnostics: vec!["invalid temporal session record".into()],
2978 };
2979 };
2980 assess_capture(
2981 data.session_bound,
2982 &entries,
2983 &events,
2984 capture.as_ref(),
2985 data.session_events_malformed,
2986 data.session_events_torn_tail,
2987 data.state.status.is_terminal(),
2988 )
2989}
2990
2991fn page_range(start: u64, length: usize, total: u64) -> String {
2992 if total == 0 || length == 0 {
2993 "empty".to_string()
2994 } else {
2995 format!("showing {}-{}", start + 1, start + length as u64)
2996 }
2997}
2998
2999fn info_lines(data: &RunData, run_id: &str, palette: &Palette) -> Vec<Line<'static>> {
3000 let state = data.state;
3001 let label =
3002 |text: &str| Span::styled(format!("{text:<14}"), Style::default().fg(palette.accent));
3003 let mut lines = vec![
3005 Line::from(vec![label("run"), Span::raw(sanitize_text(run_id))]),
3006 Line::from(vec![
3007 label("workflow"),
3008 Span::raw(sanitize_text(&state.workflow_name)),
3009 ]),
3010 Line::from(vec![
3011 label("status"),
3012 Span::styled(
3013 state.status.label().to_string(),
3014 status_style(state.status, palette),
3015 ),
3016 ]),
3017 Line::from(vec![
3018 label("started"),
3019 Span::raw(sanitize_text(&state.started_at)),
3020 ]),
3021 ];
3022 if let Some(finished) = &state.finished_at {
3023 lines.push(Line::from(vec![
3024 label("finished"),
3025 Span::raw(sanitize_text(finished)),
3026 ]));
3027 }
3028 if let Some(source) = &state.workflow_source {
3029 lines.push(Line::from(vec![
3030 label("source"),
3031 Span::raw(sanitize_text(&source.display())),
3032 ]));
3033 }
3034 if let Some(detail) = &state.status_detail {
3035 lines.push(Line::from(vec![
3036 label("detail"),
3037 Span::raw(sanitize_text(detail)),
3038 ]));
3039 }
3040 if let Some(error) = &state.error {
3041 lines.push(Line::from(vec![
3042 label("error"),
3043 Span::styled(sanitize_text(error), Style::default().fg(palette.error)),
3044 ]));
3045 }
3046 lines.push(Line::from(vec![
3047 label("trace"),
3048 Span::raw(format!(
3049 "{} events (seq {})",
3050 data.trace_total, state.trace_seq
3051 )),
3052 ]));
3053 lines.extend(progress_info_lines(data.events, palette));
3054 if !data.settings_scopes.is_empty() {
3055 lines.push(Line::from(vec![
3056 label("settings"),
3057 Span::raw(format!(
3058 "{} scope(s) · {}",
3059 data.settings_total,
3060 page_range(
3061 data.settings_start,
3062 data.settings_scopes.len(),
3063 data.settings_total
3064 )
3065 )),
3066 ]));
3067 for scope in data.settings_scopes {
3068 let mount = scope
3069 .get("mountPath")
3070 .and_then(Value::as_str)
3071 .filter(|value| !value.is_empty())
3072 .unwrap_or("root");
3073 let invocation = scope.get("invocation").and_then(Value::as_u64).unwrap_or(0);
3074 let change = scope
3075 .get("changeNumber")
3076 .and_then(Value::as_u64)
3077 .unwrap_or(0);
3078 lines.push(Line::from(vec![
3079 label("settings scope"),
3080 Span::raw(format!(
3081 "{} #{} · change {}",
3082 sanitize_text(mount),
3083 invocation,
3084 change
3085 )),
3086 ]));
3087 }
3088 }
3089 if let Some(queue) = data.follow_up_queue {
3090 let presentation = queue
3091 .get("presentationState")
3092 .and_then(Value::as_str)
3093 .unwrap_or("unknown");
3094 let items = queue
3095 .get("items")
3096 .and_then(Value::as_array)
3097 .cloned()
3098 .unwrap_or_default();
3099 lines.push(Line::from(vec![
3100 label("follow-ups"),
3101 Span::raw(format!(
3102 "{} item(s) · {} · presentation {}",
3103 data.follow_up_total,
3104 page_range(data.follow_up_start, items.len(), data.follow_up_total),
3105 sanitize_text(presentation)
3106 )),
3107 ]));
3108 for item in items {
3109 let order = item.get("order").and_then(Value::as_u64).unwrap_or(0);
3110 let state = item
3111 .get("state")
3112 .and_then(Value::as_str)
3113 .unwrap_or("unknown");
3114 lines.push(Line::from(vec![
3115 label("follow-up"),
3116 Span::raw(format!("{} · {}", order, sanitize_text(state))),
3117 ]));
3118 }
3119 }
3120 if let Some(updates) = &state.updates {
3121 lines.push(Line::from(vec![
3122 label("updates"),
3123 Span::raw(format!(
3124 "{} current key(s) · {}",
3125 data.update_total,
3126 page_range(data.update_start, updates.len(), data.update_total)
3127 )),
3128 ]));
3129 }
3130 if data.settings_total > data.settings_scopes.len() as u64
3131 || data.follow_up_total
3132 > data
3133 .follow_up_queue
3134 .and_then(|queue| queue.get("items"))
3135 .and_then(Value::as_array)
3136 .map_or(0, |items| items.len() as u64)
3137 || data.update_total
3138 > state
3139 .updates
3140 .as_ref()
3141 .map_or(0, |updates| updates.len() as u64)
3142 {
3143 lines.push(Line::from(Span::styled(
3144 "< previous inspector page · > next inspector page",
3145 Style::default().fg(palette.muted),
3146 )));
3147 }
3148 let capture = capture_integrity(data);
3149 lines.push(Line::from(vec![
3150 label("session"),
3151 Span::raw(if data.session_bound {
3152 format!(
3153 "{} entries · {} events",
3154 data.session_entry_total, data.session_event_total
3155 )
3156 } else {
3157 "not bound".to_string()
3158 }),
3159 ]));
3160 lines.push(Line::from(vec![
3161 label("capture"),
3162 Span::styled(
3163 capture.status.to_string(),
3164 if matches!(capture.status, "failed" | "invalid") {
3165 Style::default().fg(palette.error)
3166 } else {
3167 Style::default().fg(palette.subtext)
3168 },
3169 ),
3170 ]));
3171 for diagnostic in capture.diagnostics {
3172 lines.push(Line::from(vec![
3173 label("capture issue"),
3174 Span::styled(
3175 sanitize_text(&diagnostic),
3176 Style::default().fg(palette.warning),
3177 ),
3178 ]));
3179 }
3180 if data.possibly_interrupted {
3181 lines.push(Line::from(Span::styled(
3182 "run may have been interrupted (no writes for 60s)",
3183 Style::default().fg(palette.timed_out),
3184 )));
3185 }
3186 if let Some(output) = &state.final_output {
3187 lines.push(Line::from(""));
3188 lines.push(Line::from(vec![
3189 label("final output"),
3190 Span::raw(preview_value(output, data.run_dir, &data.remote_artifacts)),
3191 ]));
3192 }
3193 lines
3194}
3195
3196fn progress_info_lines(events: &[Value], palette: &Palette) -> Vec<Line<'static>> {
3197 let mut tracks: HashMap<String, Vec<(i64, Value)>> = HashMap::new();
3198 for event in events {
3199 if event.get("type").and_then(Value::as_str) != Some("update_published")
3200 || event.pointer("/payload/type").and_then(Value::as_str) != Some("progress")
3201 {
3202 continue;
3203 }
3204 let Some(key) = event.pointer("/payload/key").and_then(Value::as_str) else {
3205 continue;
3206 };
3207 let Some(data) = event
3208 .pointer("/payload/data")
3209 .filter(|value| value.is_object())
3210 else {
3211 continue;
3212 };
3213 let Some(at) = event
3214 .get("at")
3215 .and_then(Value::as_str)
3216 .and_then(parse_timestamp_ms)
3217 else {
3218 continue;
3219 };
3220 tracks
3221 .entry(key.to_string())
3222 .or_default()
3223 .push((at, data.clone()));
3224 }
3225 let mut keys: Vec<String> = tracks.keys().cloned().collect();
3226 keys.sort_by_key(|key| (key != "overall", key.clone()));
3227 if keys.is_empty() {
3228 return Vec::new();
3229 }
3230 let label =
3231 |text: &str| Span::styled(format!("{text:<14}"), Style::default().fg(palette.accent));
3232 let mut lines = vec![Line::from("")];
3233 for key in keys {
3234 let samples = tracks.get(&key).expect("progress key exists");
3235 let Some((latest_at, latest)) = samples.last() else {
3236 continue;
3237 };
3238 let name = latest.get("label").and_then(Value::as_str).unwrap_or(&key);
3239 let status = latest
3240 .get("status")
3241 .and_then(Value::as_str)
3242 .unwrap_or("unknown");
3243 let completed = latest.get("completed").and_then(Value::as_f64);
3244 let total = latest.get("total").and_then(Value::as_f64);
3245 let unit = latest.get("unit").and_then(Value::as_str).unwrap_or("");
3246 let count = match (completed, total) {
3247 (Some(done), Some(all)) => format!(
3248 "{} / {} {}",
3249 compact_number(done),
3250 compact_number(all),
3251 sanitize_text(unit)
3252 ),
3253 (Some(done), None) => format!("{} {}", compact_number(done), sanitize_text(unit)),
3254 _ => status.to_string(),
3255 };
3256 lines.push(Line::from(vec![
3257 label("progress"),
3258 Span::raw(format!("{} · {}", sanitize_text(name), count.trim())),
3259 ]));
3260
3261 let mut detail = Vec::new();
3262 let source_at = latest
3263 .get("sourceUpdatedAt")
3264 .and_then(Value::as_str)
3265 .and_then(parse_timestamp_ms)
3266 .unwrap_or(*latest_at);
3267 let source_eta = latest
3268 .get("sourceEstimatedFinishAt")
3269 .and_then(Value::as_str)
3270 .and_then(parse_timestamp_ms)
3271 .filter(|finish| *finish > source_at && *finish > now_ms());
3272 let terminal = matches!(status, "completed" | "failed" | "cancelled");
3273 if !terminal {
3274 if let Some(finish) = source_eta {
3275 detail.push(format!("source ETA {}", format_eta_ms(finish - now_ms())));
3276 } else if !matches!(status, "waiting" | "blocked") {
3277 let rates = progress_rates(current_progress_epoch(samples));
3278 if let (Some(all), Some(done), Some(median)) =
3279 (total, completed, median_value(&rates))
3280 {
3281 if median > 0.0 {
3282 detail.push(format!(
3283 "ETA {}",
3284 format_eta_ms(((all - done).max(0.0) / median) as i64)
3285 ));
3286 detail.push(format!("rate {}/min", compact_number(median * 60_000.0)));
3287 detail.push(format!("{} confidence", progress_confidence(&rates)));
3288 } else {
3289 detail.push("ETA unavailable".to_string());
3290 }
3291 } else {
3292 detail.push("ETA unavailable".to_string());
3293 }
3294 }
3295 }
3296 detail.push(format!("{} samples", current_progress_epoch(samples).len()));
3297 detail.push(format!(
3298 "updated {}",
3299 format_eta_ms((now_ms() - *latest_at).max(0))
3300 ));
3301 lines.push(Line::from(vec![
3302 label("estimate"),
3303 Span::styled(detail.join(" · "), Style::default().fg(palette.subtext)),
3304 ]));
3305 }
3306 lines
3307}
3308
3309fn current_progress_epoch(samples: &[(i64, Value)]) -> &[(i64, Value)] {
3310 let mut start = 0;
3311 for index in 1..samples.len() {
3312 if progress_resets(&samples[index - 1].1, &samples[index].1) {
3313 start = index;
3314 }
3315 }
3316 &samples[start..]
3317}
3318
3319fn progress_resets(previous: &Value, current: &Value) -> bool {
3320 let changed_identity = previous.get("phase") != current.get("phase")
3321 || previous.get("unit") != current.get("unit")
3322 || previous.get("total") != current.get("total");
3323 let decreased = match (
3324 previous.get("completed").and_then(Value::as_f64),
3325 current.get("completed").and_then(Value::as_f64),
3326 ) {
3327 (Some(before), Some(after)) => after < before,
3328 _ => false,
3329 };
3330 let previous_status = previous
3331 .get("status")
3332 .and_then(Value::as_str)
3333 .unwrap_or("unknown");
3334 let current_status = current
3335 .get("status")
3336 .and_then(Value::as_str)
3337 .unwrap_or("unknown");
3338 changed_identity
3339 || decreased
3340 || (matches!(previous_status, "completed" | "failed" | "cancelled")
3341 && !matches!(current_status, "completed" | "failed" | "cancelled"))
3342}
3343
3344fn progress_rates(samples: &[(i64, Value)]) -> Vec<f64> {
3345 let start = samples.len().saturating_sub(9);
3346 let mut rates = Vec::new();
3347 for pair in samples[start..].windows(2) {
3348 let (previous_at, previous) = &pair[0];
3349 let (current_at, current) = &pair[1];
3350 let elapsed = current_at - previous_at;
3351 let before = previous.get("completed").and_then(Value::as_f64);
3352 let after = current.get("completed").and_then(Value::as_f64);
3353 if elapsed > 0 && before.is_some() && after.is_some() {
3354 rates.push((after.unwrap_or(0.0) - before.unwrap_or(0.0)) / elapsed as f64);
3355 }
3356 }
3357 rates.sort_by(f64::total_cmp);
3358 rates
3359}
3360
3361fn median_value(values: &[f64]) -> Option<f64> {
3362 if values.is_empty() {
3363 return None;
3364 }
3365 let middle = values.len() / 2;
3366 Some(if values.len().is_multiple_of(2) {
3367 (values[middle - 1] + values[middle]) / 2.0
3368 } else {
3369 values[middle]
3370 })
3371}
3372
3373fn progress_confidence(rates: &[f64]) -> &'static str {
3374 if rates.len() < 2 {
3375 return "low";
3376 }
3377 let median = median_value(rates).unwrap_or(0.0);
3378 if median <= 0.0 {
3379 return "low";
3380 }
3381 let p25 = rates[((rates.len() - 1) as f64 * 0.25).round() as usize];
3382 let p75 = rates[((rates.len() - 1) as f64 * 0.75).round() as usize];
3383 let spread = (p75 - p25) / median;
3384 if rates.len() >= 5 && spread <= 0.25 {
3385 "high"
3386 } else if spread <= 0.5 {
3387 "medium"
3388 } else {
3389 "low"
3390 }
3391}
3392
3393fn compact_number(value: f64) -> String {
3394 if value.fract().abs() < f64::EPSILON {
3395 format!("{value:.0}")
3396 } else {
3397 format!("{value:.2}")
3398 .trim_end_matches('0')
3399 .trim_end_matches('.')
3400 .to_string()
3401 }
3402}
3403
3404fn format_eta_ms(ms: i64) -> String {
3405 let seconds = ms.max(0) / 1_000;
3406 if seconds < 60 {
3407 format!("{seconds}s")
3408 } else if seconds < 3_600 {
3409 format!("{}m", (seconds + 59) / 60)
3410 } else if seconds < 86_400 {
3411 format!("{:.1}h", seconds as f64 / 3_600.0)
3412 } else {
3413 format!("{:.1}d", seconds as f64 / 86_400.0)
3414 }
3415}
3416
3417struct TransportOptions<'a> {
3418 temporal_replay: Option<i64>,
3419 playing: bool,
3420 speed: u16,
3421 diagnostic: Option<&'a str>,
3422}
3423
3424fn draw_transport(
3425 frame: &mut Frame,
3426 area: Rect,
3427 data: Option<(&RunData, i64, bool)>,
3428 options: TransportOptions<'_>,
3429 palette: &Palette,
3430) -> timeline::TimelineGeometry {
3431 let elapsed = data.map(|(data, _, _)| {
3432 let state = data.state;
3433 let end = state
3434 .finished_at
3435 .as_deref()
3436 .and_then(parse_timestamp_ms)
3437 .unwrap_or_else(now_ms);
3438 let start = parse_timestamp_ms(&state.started_at).unwrap_or(end);
3439 format_duration((end - start).max(0))
3440 });
3441 let view = data.map(|(data, bounded_index, at_latest)| {
3442 let temporal = data.session_event_total > 0;
3443 timeline::TimelineView {
3444 status: data.state.status,
3445 paused: data.state.paused == Some(true),
3446 elapsed: elapsed.as_deref().unwrap_or("0ms"),
3447 steps: if temporal {
3448 data.session_event_total as usize
3449 } else {
3450 data.step_total as usize
3451 },
3452 position: if temporal {
3453 options
3454 .temporal_replay
3455 .unwrap_or(data.session_event_total as i64 - 1)
3456 } else {
3457 bounded_index
3458 },
3459 temporal,
3460 at_latest,
3461 live: data.live,
3462 playing: options.playing,
3463 speed: options.speed,
3464 diagnostic: options.diagnostic,
3465 }
3466 });
3467 timeline::render(frame, area, view, palette)
3468}
3469
3470#[cfg(test)]
3471mod tests {
3472 use super::{
3473 centered_camera, clamp_camera_axis, collect_artifact_paths, completed_step_at, contains,
3474 current_progress_epoch, graph_position_label, inspector_height_for_drag,
3475 inspector_tab_label, inspector_tab_layout, next_page_cursor, page_range, progress_rates,
3476 push_human_decision_presentation, resolve_remote_artifacts, resolved_inspector_height,
3477 sidebar_width_for_drag, step_projection_contains, temporal_delay_from_page,
3478 temporal_through_seq, trace_events_for_scope, valid_session_binding, GraphNodeStyle,
3479 InspectorTab, NodeBounds, Palette, Rect, StepRecord, TemporalDelay, TraceScope,
3480 DEFAULT_NODE_STYLE,
3481 };
3482 use serde_json::json;
3483 use std::collections::HashMap;
3484 use std::time::Duration;
3485
3486 #[test]
3487 fn progress_estimation_resets_on_phase_change() {
3488 let samples = vec![
3489 (
3490 0,
3491 json!({ "status": "running", "phase": "one", "completed": 0, "total": 100, "unit": "rows" }),
3492 ),
3493 (
3494 1_000,
3495 json!({ "status": "running", "phase": "one", "completed": 10, "total": 100, "unit": "rows" }),
3496 ),
3497 (
3498 2_000,
3499 json!({ "status": "running", "phase": "two", "completed": 0, "total": 50, "unit": "rows" }),
3500 ),
3501 (
3502 3_000,
3503 json!({ "status": "running", "phase": "two", "completed": 5, "total": 50, "unit": "rows" }),
3504 ),
3505 ];
3506 let epoch = current_progress_epoch(&samples);
3507 assert_eq!(epoch.len(), 2);
3508 assert_eq!(progress_rates(epoch), vec![0.005]);
3509 }
3510
3511 #[test]
3512 fn bordered_nodes_are_the_default() {
3513 assert_eq!(DEFAULT_NODE_STYLE, GraphNodeStyle::Box);
3514 }
3515
3516 #[test]
3517 fn inspector_tabs_are_visible_full_label_mouse_targets() {
3518 let area = Rect::new(10, 4, 80, 1);
3519 let hits = inspector_tab_layout(area);
3520 assert_eq!(hits.len(), InspectorTab::ALL.len());
3521 for hit in &hits {
3522 let label = inspector_tab_label(hit.tab, area.width);
3523 assert_eq!(hit.rect.width, label.chars().count() as u16);
3524 assert_eq!(
3525 hits.iter()
3526 .find(|candidate| { contains(candidate.rect, hit.rect.x, hit.rect.y) })
3527 .map(|candidate| candidate.tab),
3528 Some(hit.tab)
3529 );
3530 }
3531 for pair in hits.windows(2) {
3532 assert_eq!(pair[1].rect.x, pair[0].rect.right() + 1);
3533 assert!(!contains(
3534 pair[0].rect,
3535 pair[0].rect.right(),
3536 pair[0].rect.y
3537 ));
3538 }
3539 }
3540
3541 #[test]
3542 fn inspector_tabs_keep_all_icon_buttons_on_narrow_panes() {
3543 let hits = inspector_tab_layout(Rect::new(0, 0, 20, 1));
3544 assert_eq!(hits.len(), InspectorTab::ALL.len());
3545 assert!(hits.iter().all(|hit| hit.rect.width == 3));
3546 }
3547
3548 #[test]
3549 fn session_binding_requires_the_supported_schema() {
3550 assert!(valid_session_binding(Some(&json!({
3551 "schema": "pi-workflows.session-binding.v1"
3552 }))));
3553 assert!(!valid_session_binding(Some(&json!({ "schema": "future" }))));
3554 assert!(!valid_session_binding(Some(&json!("binding"))));
3555 assert!(!valid_session_binding(None));
3556 }
3557
3558 #[test]
3559 fn temporal_playback_waits_for_missing_pages() {
3560 let tail = vec![serde_json::json!({"at": "2026-01-01T00:12:24.000Z"})];
3561 assert_eq!(
3562 temporal_delay_from_page(&tail, 744, -1, 1),
3563 TemporalDelay::Pending(0)
3564 );
3565 let first = vec![serde_json::json!({"at": "2026-01-01T00:00:00.000Z"})];
3566 assert_eq!(
3567 temporal_delay_from_page(&first, 0, -1, 1),
3568 TemporalDelay::Ready(Duration::ZERO)
3569 );
3570 let page = (0..256)
3571 .map(|second| {
3572 serde_json::json!({"at": format!("2026-01-01T00:{:02}:{:02}.000Z", second / 60, second % 60)})
3573 })
3574 .collect::<Vec<_>>();
3575 assert_eq!(
3576 temporal_delay_from_page(&page, 0, 255, 1),
3577 TemporalDelay::Pending(256)
3578 );
3579 }
3580
3581 #[test]
3582 fn inspector_pages_keep_a_complete_navigation_path() {
3583 assert_eq!(next_page_cursor(0, 600, 256, 1), Some(256));
3584 assert_eq!(next_page_cursor(256, 600, 256, -1), Some(255));
3585 assert_eq!(next_page_cursor(344, 600, 256, 1), Some(472));
3586 assert_eq!(page_range(256, 256, 600), "showing 257-512");
3587 }
3588
3589 #[test]
3590 fn replay_requires_graph_state_for_the_exact_cursor() {
3591 assert!(step_projection_contains(130, 130, 0, 256));
3592 assert!(!step_projection_contains(129, 130, 0, 256));
3593 assert!(!step_projection_contains(300, 300, 0, 256));
3594 }
3595
3596 #[test]
3597 fn pre_capture_temporal_position_maps_to_sequence_zero() {
3598 let events = vec![serde_json::json!({ "seq": 1 })];
3599 assert_eq!(temporal_through_seq(&events, -1), 0);
3600 assert_eq!(temporal_through_seq(&events, 0), 1);
3601 }
3602
3603 #[test]
3604 fn temporal_replay_hides_attempts_until_their_finish_time() {
3605 let steps = vec![StepRecord {
3606 attempt_id: "a1".into(),
3607 node_id: "agent".into(),
3608 node_type: "agent".into(),
3609 outcome: crate::state::types::NodeOutcome::Ok,
3610 started_at: "2026-01-01T00:00:01.000Z".into(),
3611 finished_at: "2026-01-01T00:00:05.000Z".into(),
3612 prompt: serde_json::Value::Null,
3613 output: serde_json::Value::Null,
3614 error: None,
3615 conversation: None,
3616 action: None,
3617 settings_scope_id: None,
3618 settings_change_number: None,
3619 settings_hash: None,
3620 assistant_message: None,
3621 }];
3622 assert_eq!(completed_step_at(&steps, 1_767_225_603_000), -1);
3623 assert_eq!(completed_step_at(&steps, 1_767_225_605_000), 0);
3624 }
3625
3626 #[test]
3627 fn graph_title_separates_replay_position_from_run_liveness() {
3628 assert_eq!(graph_position_label(false, true), "(replay)");
3629 assert_eq!(graph_position_label(false, false), "(replay)");
3630 assert_eq!(graph_position_label(true, true), "(live)");
3631 assert_eq!(graph_position_label(true, false), "(latest)");
3632 }
3633
3634 #[test]
3635 fn follow_camera_centers_the_node_even_at_canvas_edges() {
3636 let node = NodeBounds {
3637 node_id: "first".into(),
3638 x: 0,
3639 y: 0,
3640 width: 20,
3641 height: 3,
3642 };
3643 assert_eq!(centered_camera(Some(&node), (100, 30), (80, 20)), (-30, -9));
3644 assert_eq!(clamp_camera_axis(-30, 100, 80), -30);
3645 assert_eq!(clamp_camera_axis(-9, 30, 20), -9);
3646 }
3647
3648 #[test]
3649 fn manual_panel_sizes_stay_responsive() {
3650 assert_eq!(resolved_inspector_height(40, None), 16);
3651 assert_eq!(resolved_inspector_height(40, Some(100)), 35);
3652 assert_eq!(resolved_inspector_height(8, Some(20)), 3);
3653 assert_eq!(sidebar_width_for_drag(Rect::new(5, 0, 120, 30), 44), 40);
3654 assert_eq!(sidebar_width_for_drag(Rect::new(5, 0, 40, 30), 100), 16);
3655 assert_eq!(inspector_height_for_drag(Rect::new(20, 2, 100, 26), 18), 10);
3656 assert_eq!(inspector_height_for_drag(Rect::new(20, 2, 100, 8), 2), 3);
3657 }
3658
3659 #[test]
3660 fn remote_artifacts_recurse_into_escaped_object_children() {
3661 let value = json!({
3662 "$escaped": {
3663 "nested": {
3664 "$artifact": {
3665 "path": "artifacts/sha256/a.txt",
3666 "mediaType": "text/plain",
3667 "bytes": 4,
3668 "sha256": "a"
3669 }
3670 }
3671 }
3672 });
3673 let mut paths = Vec::new();
3674 collect_artifact_paths(&value, &mut paths);
3675 assert_eq!(paths, vec!["artifacts/sha256/a.txt"]);
3676 let artifacts =
3677 HashMap::from([("artifacts/sha256/a.txt".to_string(), Ok("body".to_string()))]);
3678 assert_eq!(
3679 resolve_remote_artifacts(&value, &artifacts),
3680 json!({"nested": "body"})
3681 );
3682 }
3683
3684 #[test]
3685 fn v2_decision_inspector_shows_presentation_without_subject() {
3686 let request = json!({
3687 "schema": "pi-workflows.human-decision-request.v1",
3688 "title": "Approve readable plan",
3689 "subject": { "hiddenMachineValue": "do-not-show" },
3690 "subjectDigest": format!("sha256:{}", "a".repeat(64)),
3691 "presentationDigest": format!("sha256:{}", "b".repeat(64)),
3692 "revision": 2,
3693 "presentation": {
3694 "summary": "Review the readable plan.",
3695 "blocks": [
3696 { "kind": "section", "title": "Changes" },
3697 { "kind": "bullets", "items": ["Apply the safe change."] }
3698 ]
3699 },
3700 "choices": {
3701 "continue": { "label": "Continue" },
3702 "replan": {
3703 "label": "Replan",
3704 "input": { "prompt": "What should change?" }
3705 }
3706 }
3707 });
3708 let mut lines = Vec::new();
3709 assert!(push_human_decision_presentation(
3710 &mut lines,
3711 &request,
3712 None,
3713 &HashMap::new(),
3714 100,
3715 &Palette::catppuccin(),
3716 ));
3717 let rendered = lines
3718 .iter()
3719 .flat_map(|line| line.spans.iter())
3720 .map(|span| span.content.as_ref())
3721 .collect::<Vec<_>>()
3722 .join("\n");
3723 assert!(rendered.contains("Review the readable plan."));
3724 assert!(rendered.contains("Apply the safe change."));
3725 assert!(rendered.contains("What should change?"));
3726 assert!(!rendered.contains("hiddenMachineValue"));
3727 assert!(!rendered.contains("do-not-show"));
3728 }
3729
3730 #[test]
3731 fn replay_visible_trace_stops_before_future_attempts() {
3732 let step: StepRecord = serde_json::from_value(json!({
3733 "attemptId": "a1",
3734 "nodeId": "plan",
3735 "nodeType": "agent",
3736 "outcome": "ok",
3737 "startedAt": "2026-01-01T00:00:00Z",
3738 "finishedAt": "2026-01-01T00:00:01Z",
3739 "prompt": null,
3740 "output": null
3741 }))
3742 .unwrap();
3743 let events = vec![
3744 json!({"seq": 1, "type": "run_started"}),
3745 json!({"seq": 2, "type": "node_started", "attemptId": "a1"}),
3746 json!({"seq": 3, "type": "node_completed", "attemptId": "a1"}),
3747 json!({"seq": 4, "type": "node_started", "attemptId": "a2"}),
3748 ];
3749 let visible = trace_events_for_scope(
3750 &events,
3751 std::slice::from_ref(&step),
3752 Some(&step),
3753 TraceScope::ReplayVisible,
3754 );
3755 assert_eq!(visible.len(), 3);
3756 assert_eq!(visible.last().unwrap()["seq"], 3);
3757 let selected = trace_events_for_scope(
3758 &events,
3759 std::slice::from_ref(&step),
3760 Some(&step),
3761 TraceScope::SelectedAttempt,
3762 );
3763 assert_eq!(selected.len(), 2);
3764 }
3765}