Skip to main content

piw/ui/
mod.rs

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