Skip to main content

piw/ui/
mod.rs

1//! The interactive TUI (see docs/tui-viewer.md): a runs sidebar, the graph
2//! pane, an inspector with steps/trace/conversation/info tabs, and a replay
3//! transport. Works against a local runs directory, a single run, or a
4//! `piw serve` WebSocket server; all three feed the same view model.
5
6mod 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
64/// Borrowed view of one run, identical for local and remote providers.
65pub 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    /// Bundle directory when reading the filesystem directly; lets previews
100    /// inline small artifacts instead of showing placeholders.
101    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    /// Whether the sidebar is shown (single-run mode hides it).
372    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    /// Workflow replay position: `None` = live/latest; `Some(i)` = after step i.
381    replay: Option<i64>,
382    /// Temporal replay position: `None` = newest event; `Some(-1)` = before
383    /// capture; other values are zero-based session-event indices.
384    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    /// Canvas coordinate shown at the viewport's top-left. Negative origins
391    /// provide the padding needed to truly center edge nodes and small graphs.
392    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    /// Pane rectangles from the last draw, for mouse routing.
412    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
438/// Render one complete run view without taking over the terminal.
439pub 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            // Consume every event due on the timestamp clock, including ties,
778            // without letting one UI frame monopolize the terminal.
779            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        // Replay transport (global).
1172        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                // Row 0 of the pane is the border/title.
1497                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        // In remote mode an empty screen is ambiguous: say whether we are
1689        // still connecting, failed, or genuinely see no runs.
1690        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    // Captured before `data` takes the mutable borrow: a dead remote
1751    // connection must be visible while a cached run is still displayed.
1752    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    // Graph pane.
1806    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    // Inspector pane. Tabs get their own control row so their complete visual
1957    // labels are also their complete mouse targets.
1958    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
2320/// Collect artifact references from the current host-owned run view.
2321fn 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
2401/// Compact single-line preview of a persisted value. Artifact references use
2402/// local checked reads or the bounded remote artifact cache.
2403fn 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    // Only the steps visible at the replay position: while scrubbing, the
2610    // pane must not reveal outcomes the graph does not show yet.
2611    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    // Everything below except the derived counts is run-derived text.
2974    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}