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