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