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