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