1mod 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
58pub 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 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 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 replay: Option<i64>,
324 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 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 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 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 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 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 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 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 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 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
1932const 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
2015fn 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 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 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}