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