Skip to main content

piw/ui/
mod.rs

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