Skip to main content

codetether_agent/tui/
ralph_view.rs

1//! Ralph view for the TUI
2//!
3//! Displays real-time status of the autonomous PRD-driven Ralph loop,
4//! showing story progress, quality gate results, and per-story sub-agent activity.
5
6use super::swarm_view::{AgentMessageEntry, AgentToolCallDetail};
7use ratatui::{
8    Frame,
9    layout::{Constraint, Direction, Layout, Rect},
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Wrap},
13};
14use tokio::sync::mpsc;
15
16// ---------------------------------------------------------------------------
17// Events
18// ---------------------------------------------------------------------------
19
20/// Events emitted by the Ralph loop for TUI updates
21#[derive(Debug, Clone)]
22pub enum RalphEvent {
23    /// Ralph loop started
24    Started {
25        project: String,
26        feature: String,
27        stories: Vec<RalphStoryInfo>,
28        max_iterations: usize,
29    },
30
31    /// New iteration started
32    IterationStarted {
33        iteration: usize,
34        max_iterations: usize,
35    },
36
37    /// Story work started
38    StoryStarted { story_id: String },
39
40    /// Agent tool call for a story (basic - name only)
41    StoryToolCall { story_id: String, tool_name: String },
42
43    /// Agent tool call with full detail
44    StoryToolCallDetail {
45        story_id: String,
46        detail: AgentToolCallDetail,
47    },
48
49    /// Agent message for a story
50    StoryMessage {
51        story_id: String,
52        entry: AgentMessageEntry,
53    },
54
55    /// Quality check result for a story
56    StoryQualityCheck {
57        story_id: String,
58        check_name: String,
59        passed: bool,
60    },
61
62    /// Story completed
63    StoryComplete { story_id: String, passed: bool },
64
65    /// Story output text
66    StoryOutput { story_id: String, output: String },
67
68    /// Story error
69    StoryError { story_id: String, error: String },
70
71    /// Merge result for a story (parallel mode)
72    StoryMerge {
73        story_id: String,
74        success: bool,
75        summary: String,
76    },
77
78    /// Stage completed (parallel mode)
79    StageComplete {
80        stage: usize,
81        completed: usize,
82        failed: usize,
83    },
84
85    /// Ralph loop complete
86    Complete {
87        status: String,
88        passed: usize,
89        total: usize,
90    },
91
92    /// Error
93    Error(String),
94}
95
96// ---------------------------------------------------------------------------
97// Story status
98// ---------------------------------------------------------------------------
99
100/// Status of an individual story in the Ralph view
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum RalphStoryStatus {
103    Pending,
104    Blocked,
105    Running,
106    QualityCheck,
107    Passed,
108    Failed,
109}
110
111// ---------------------------------------------------------------------------
112// Story info
113// ---------------------------------------------------------------------------
114
115/// Information about a story for display
116#[derive(Debug, Clone)]
117pub struct RalphStoryInfo {
118    pub id: String,
119    pub title: String,
120    pub status: RalphStoryStatus,
121    pub priority: u8,
122    pub depends_on: Vec<String>,
123    /// Quality check results: (name, passed)
124    pub quality_checks: Vec<(String, bool)>,
125    /// Tool call history from sub-agent
126    pub tool_call_history: Vec<AgentToolCallDetail>,
127    /// Conversation messages from sub-agent
128    pub messages: Vec<AgentMessageEntry>,
129    /// Final output text
130    pub output: Option<String>,
131    /// Error message if failed
132    pub error: Option<String>,
133    /// Merge result summary
134    pub merge_summary: Option<String>,
135    /// Step counter for agent
136    pub steps: usize,
137    /// Current tool being executed
138    pub current_tool: Option<String>,
139}
140
141// ---------------------------------------------------------------------------
142// View state
143// ---------------------------------------------------------------------------
144
145/// State for the Ralph view
146#[derive(Debug, Default)]
147pub struct RalphViewState {
148    /// Whether Ralph mode is active
149    pub active: bool,
150    /// Project name
151    pub project: String,
152    /// Feature name
153    pub feature: String,
154    /// All stories
155    pub stories: Vec<RalphStoryInfo>,
156    /// Current iteration
157    pub current_iteration: usize,
158    /// Maximum iterations
159    pub max_iterations: usize,
160    /// Whether execution is complete
161    pub complete: bool,
162    /// Final status string
163    pub final_status: Option<String>,
164    /// Any error message
165    pub error: Option<String>,
166    /// Currently selected story index
167    pub selected_index: usize,
168    /// Whether we're in detail mode (viewing a single story's agent)
169    pub detail_mode: bool,
170    /// Scroll offset within the detail view
171    pub detail_scroll: usize,
172    /// ListState for StatefulWidget rendering
173    pub list_state: ListState,
174    /// Inbound channel for events from a Ralph loop spawned via `/ralph run`.
175    ///
176    /// `None` when the view is being used purely as a passive monitor. When
177    /// a run is launched from the TUI, the slash-command handler installs a
178    /// receiver here and the tick loop drains it into [`handle_event`].
179    #[doc(hidden)]
180    pub event_rx: Option<mpsc::Receiver<RalphEvent>>,
181}
182
183impl RalphViewState {
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Install an event receiver connected to a live `RalphLoop` and reset
189    /// the view so it starts clean for the new run.
190    pub fn attach_event_rx(&mut self, rx: mpsc::Receiver<RalphEvent>) {
191        self.event_rx = Some(rx);
192        self.active = true;
193        self.complete = false;
194        self.final_status = None;
195        self.error = None;
196    }
197
198    /// Drain any pending events from [`Self::event_rx`] without blocking.
199    ///
200    /// Returns `true` when at least one event was applied, so callers can
201    /// cheap-gate a redraw. When the upstream sender is dropped, the
202    /// receiver is closed automatically via `None` from `try_recv`.
203    pub fn drain_events(&mut self) -> bool {
204        let Some(mut rx) = self.event_rx.take() else {
205            return false;
206        };
207        let mut any = false;
208        loop {
209            match rx.try_recv() {
210                Ok(evt) => {
211                    any = true;
212                    self.handle_event(evt);
213                }
214                Err(mpsc::error::TryRecvError::Empty) => {
215                    self.event_rx = Some(rx);
216                    break;
217                }
218                Err(mpsc::error::TryRecvError::Disconnected) => {
219                    // The Ralph task finished or panicked. Leave the
220                    // slot None so subsequent ticks are O(1) no-ops.
221                    break;
222                }
223            }
224        }
225        any
226    }
227
228    pub fn mark_active(&mut self, project: impl Into<String>, feature: impl Into<String>) {
229        self.handle_event(RalphEvent::Started {
230            project: project.into(),
231            feature: feature.into(),
232            stories: Vec::new(),
233            max_iterations: 0,
234        });
235    }
236
237    /// Handle a Ralph event
238    pub fn handle_event(&mut self, event: RalphEvent) {
239        match event {
240            RalphEvent::Started {
241                project,
242                feature,
243                stories,
244                max_iterations,
245            } => {
246                self.active = true;
247                self.project = project;
248                self.feature = feature;
249                self.stories = stories;
250                self.max_iterations = max_iterations;
251                self.current_iteration = 0;
252                self.complete = false;
253                self.final_status = None;
254                self.error = None;
255                self.selected_index = 0;
256                self.detail_mode = false;
257                self.detail_scroll = 0;
258                self.list_state = ListState::default();
259                if !self.stories.is_empty() {
260                    self.list_state.select(Some(0));
261                }
262            }
263            RalphEvent::IterationStarted {
264                iteration,
265                max_iterations,
266            } => {
267                self.current_iteration = iteration;
268                self.max_iterations = max_iterations;
269            }
270            RalphEvent::StoryStarted { story_id } => {
271                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
272                    story.status = RalphStoryStatus::Running;
273                    story.steps = 0;
274                    story.current_tool = None;
275                    story.quality_checks.clear();
276                    story.tool_call_history.clear();
277                    story.messages.clear();
278                    story.output = None;
279                    story.error = None;
280                }
281            }
282            RalphEvent::StoryToolCall {
283                story_id,
284                tool_name,
285            } => {
286                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
287                    story.current_tool = Some(tool_name);
288                    story.steps += 1;
289                }
290            }
291            RalphEvent::StoryToolCallDetail { story_id, detail } => {
292                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
293                    story.current_tool = Some(detail.tool_name.clone());
294                    story.steps += 1;
295                    story.tool_call_history.push(detail);
296                }
297            }
298            RalphEvent::StoryMessage { story_id, entry } => {
299                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
300                    story.messages.push(entry);
301                }
302            }
303            RalphEvent::StoryQualityCheck {
304                story_id,
305                check_name,
306                passed,
307            } => {
308                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
309                    story.status = RalphStoryStatus::QualityCheck;
310                    story.current_tool = None;
311                    story.quality_checks.push((check_name, passed));
312                }
313            }
314            RalphEvent::StoryComplete { story_id, passed } => {
315                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
316                    story.status = if passed {
317                        RalphStoryStatus::Passed
318                    } else {
319                        RalphStoryStatus::Failed
320                    };
321                    story.current_tool = None;
322                }
323            }
324            RalphEvent::StoryOutput { story_id, output } => {
325                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
326                    story.output = Some(output);
327                }
328            }
329            RalphEvent::StoryError { story_id, error } => {
330                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
331                    story.error = Some(error.clone());
332                    story.status = RalphStoryStatus::Failed;
333                }
334            }
335            RalphEvent::StoryMerge {
336                story_id,
337                success: _,
338                summary,
339            } => {
340                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
341                    story.merge_summary = Some(summary);
342                }
343            }
344            RalphEvent::StageComplete { .. } => {
345                // Could track stage progress if needed
346            }
347            RalphEvent::Complete {
348                status,
349                passed: _,
350                total: _,
351            } => {
352                self.complete = true;
353                self.final_status = Some(status);
354            }
355            RalphEvent::Error(err) => {
356                self.error = Some(err);
357            }
358        }
359    }
360
361    /// Move selection up
362    pub fn select_prev(&mut self) {
363        if self.stories.is_empty() {
364            return;
365        }
366        self.selected_index = self.selected_index.saturating_sub(1);
367        self.list_state.select(Some(self.selected_index));
368    }
369
370    /// Move selection down
371    pub fn select_next(&mut self) {
372        if self.stories.is_empty() {
373            return;
374        }
375        self.selected_index = (self.selected_index + 1).min(self.stories.len() - 1);
376        self.list_state.select(Some(self.selected_index));
377    }
378
379    /// Enter detail mode for the selected story
380    pub fn enter_detail(&mut self) {
381        if !self.stories.is_empty() {
382            self.detail_mode = true;
383            self.detail_scroll = 0;
384        }
385    }
386
387    /// Exit detail mode
388    pub fn exit_detail(&mut self) {
389        self.detail_mode = false;
390        self.detail_scroll = 0;
391    }
392
393    /// Scroll detail view down
394    pub fn detail_scroll_down(&mut self, amount: usize) {
395        self.detail_scroll = self.detail_scroll.saturating_add(amount);
396    }
397
398    /// Scroll detail view up
399    pub fn detail_scroll_up(&mut self, amount: usize) {
400        self.detail_scroll = self.detail_scroll.saturating_sub(amount);
401    }
402
403    /// Get the currently selected story
404    pub fn selected_story(&self) -> Option<&RalphStoryInfo> {
405        self.stories.get(self.selected_index)
406    }
407
408    /// Status counts
409    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
410        let mut pending = 0;
411        let mut running = 0;
412        let mut passed = 0;
413        let mut failed = 0;
414        for story in &self.stories {
415            match story.status {
416                RalphStoryStatus::Pending | RalphStoryStatus::Blocked => pending += 1,
417                RalphStoryStatus::Running | RalphStoryStatus::QualityCheck => running += 1,
418                RalphStoryStatus::Passed => passed += 1,
419                RalphStoryStatus::Failed => failed += 1,
420            }
421        }
422        (pending, running, passed, failed)
423    }
424
425    /// Overall progress as percentage
426    pub fn progress(&self) -> f64 {
427        if self.stories.is_empty() {
428            return 0.0;
429        }
430        let (_, _, passed, failed) = self.status_counts();
431        ((passed + failed) as f64 / self.stories.len() as f64) * 100.0
432    }
433}
434
435// ---------------------------------------------------------------------------
436// Rendering
437// ---------------------------------------------------------------------------
438
439/// Render the Ralph view
440pub fn render_ralph_view(f: &mut Frame, state: &mut RalphViewState, area: Rect) {
441    if state.detail_mode {
442        render_story_detail(f, state, area);
443        return;
444    }
445
446    let chunks = Layout::default()
447        .direction(Direction::Vertical)
448        .constraints([
449            Constraint::Length(4), // Header: project/feature + iteration
450            Constraint::Length(3), // Progress bar
451            Constraint::Min(1),    // Story list
452            Constraint::Length(3), // Footer / status
453        ])
454        .split(area);
455
456    render_header(f, state, chunks[0]);
457    render_progress(f, state, chunks[1]);
458    render_story_list(f, state, chunks[2]);
459    render_footer(f, state, chunks[3]);
460}
461
462fn render_header(f: &mut Frame, state: &RalphViewState, area: Rect) {
463    let (pending, running, passed, failed) = state.status_counts();
464    let total = state.stories.len();
465
466    let title = if state.complete {
467        if state.error.is_some() {
468            " Ralph [ERROR] "
469        } else if failed > 0 {
470            " Ralph [PARTIAL] "
471        } else {
472            " Ralph [COMPLETE] "
473        }
474    } else {
475        " Ralph [ACTIVE] "
476    };
477
478    let border_color = if state.complete {
479        if state.error.is_some() || failed > 0 {
480            Color::Red
481        } else {
482            Color::Green
483        }
484    } else {
485        Color::Magenta
486    };
487
488    let lines = vec![
489        Line::from(vec![
490            Span::styled("Project: ", Style::default().fg(Color::DarkGray)),
491            Span::styled(&state.project, Style::default().fg(Color::White)),
492            Span::raw("  "),
493            Span::styled("Feature: ", Style::default().fg(Color::DarkGray)),
494            Span::styled(&state.feature, Style::default().fg(Color::White)),
495        ]),
496        Line::from(vec![
497            Span::styled(
498                format!(
499                    "Iteration: {}/{}",
500                    state.current_iteration, state.max_iterations
501                ),
502                Style::default().fg(Color::Cyan),
503            ),
504            Span::raw("  "),
505            Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
506            Span::raw(" "),
507            Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
508            Span::raw(" "),
509            Span::styled(format!("✓{}", passed), Style::default().fg(Color::Green)),
510            Span::raw(" "),
511            Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
512            Span::raw(" "),
513            Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
514        ]),
515    ];
516
517    let paragraph = Paragraph::new(lines).block(
518        Block::default()
519            .borders(Borders::ALL)
520            .title(title)
521            .border_style(Style::default().fg(border_color)),
522    );
523    f.render_widget(paragraph, area);
524}
525
526fn render_progress(f: &mut Frame, state: &RalphViewState, area: Rect) {
527    let progress = state.progress();
528    let (_, _, passed, _) = state.status_counts();
529    let total = state.stories.len();
530    let label = format!("{}/{} stories — {:.0}%", passed, total, progress);
531
532    let gauge = Gauge::default()
533        .block(Block::default().borders(Borders::ALL).title(" Progress "))
534        .gauge_style(Style::default().fg(Color::Magenta).bg(Color::DarkGray))
535        .percent(progress as u16)
536        .label(label);
537
538    f.render_widget(gauge, area);
539}
540
541fn render_story_list(f: &mut Frame, state: &mut RalphViewState, area: Rect) {
542    state.list_state.select(Some(state.selected_index));
543
544    let items: Vec<ListItem> = state
545        .stories
546        .iter()
547        .map(|story| {
548            let (icon, color) = match story.status {
549                RalphStoryStatus::Pending => ("○", Color::DarkGray),
550                RalphStoryStatus::Blocked => ("⊘", Color::Yellow),
551                RalphStoryStatus::Running => ("●", Color::Cyan),
552                RalphStoryStatus::QualityCheck => ("◎", Color::Magenta),
553                RalphStoryStatus::Passed => ("✓", Color::Green),
554                RalphStoryStatus::Failed => ("✗", Color::Red),
555            };
556
557            let mut spans = vec![
558                Span::styled(format!("{} ", icon), Style::default().fg(color)),
559                Span::styled(
560                    format!("[{}] ", story.id),
561                    Style::default().fg(Color::DarkGray),
562                ),
563                Span::styled(&story.title, Style::default().fg(Color::White)),
564            ];
565
566            // Show current tool for running stories
567            if story.status == RalphStoryStatus::Running {
568                if let Some(ref tool) = story.current_tool {
569                    spans.push(Span::styled(
570                        format!(" → {}", tool),
571                        Style::default()
572                            .fg(Color::Yellow)
573                            .add_modifier(Modifier::DIM),
574                    ));
575                }
576                if story.steps > 0 {
577                    spans.push(Span::styled(
578                        format!(" (step {})", story.steps),
579                        Style::default().fg(Color::DarkGray),
580                    ));
581                }
582            }
583
584            // Show quality check status
585            if story.status == RalphStoryStatus::QualityCheck {
586                let qc_summary: Vec<&str> = story
587                    .quality_checks
588                    .iter()
589                    .map(|(name, _passed)| name.as_str())
590                    .collect();
591                if !qc_summary.is_empty() {
592                    let checks: String = story
593                        .quality_checks
594                        .iter()
595                        .map(|(name, passed)| {
596                            if *passed {
597                                format!("✓{}", name)
598                            } else {
599                                format!("✗{}", name)
600                            }
601                        })
602                        .collect::<Vec<_>>()
603                        .join(" ");
604                    spans.push(Span::styled(
605                        format!(" [{}]", checks),
606                        Style::default().fg(Color::Magenta),
607                    ));
608                }
609            }
610
611            // Show passed quality checks for completed stories
612            if story.status == RalphStoryStatus::Passed && !story.quality_checks.is_empty() {
613                let all_passed = story.quality_checks.iter().all(|(_, p)| *p);
614                if all_passed {
615                    spans.push(Span::styled(
616                        " ✓QC",
617                        Style::default()
618                            .fg(Color::Green)
619                            .add_modifier(Modifier::DIM),
620                    ));
621                }
622            }
623
624            ListItem::new(Line::from(spans))
625        })
626        .collect();
627
628    let title = if state.stories.is_empty() {
629        " Stories (loading...) "
630    } else {
631        " Stories (↑↓:select  Enter:detail) "
632    };
633
634    let list = List::new(items)
635        .block(Block::default().borders(Borders::ALL).title(title))
636        .highlight_style(
637            Style::default()
638                .add_modifier(Modifier::BOLD)
639                .bg(Color::DarkGray),
640        )
641        .highlight_symbol("▶ ");
642
643    f.render_stateful_widget(list, area, &mut state.list_state);
644}
645
646/// Render full-screen detail view for the selected story's sub-agent
647fn render_story_detail(f: &mut Frame, state: &RalphViewState, area: Rect) {
648    let story = match state.selected_story() {
649        Some(s) => s,
650        None => {
651            let p = Paragraph::new("No story selected").block(
652                Block::default()
653                    .borders(Borders::ALL)
654                    .title(" Story Detail "),
655            );
656            f.render_widget(p, area);
657            return;
658        }
659    };
660
661    let chunks = Layout::default()
662        .direction(Direction::Vertical)
663        .constraints([
664            Constraint::Length(6), // Story info header
665            Constraint::Min(1),    // Content
666            Constraint::Length(1), // Key hints
667        ])
668        .split(area);
669
670    // --- Header ---
671    let (status_text, status_color) = match story.status {
672        RalphStoryStatus::Pending => ("○ Pending", Color::DarkGray),
673        RalphStoryStatus::Blocked => ("⊘ Blocked", Color::Yellow),
674        RalphStoryStatus::Running => ("● Running", Color::Cyan),
675        RalphStoryStatus::QualityCheck => ("◎ Quality Check", Color::Magenta),
676        RalphStoryStatus::Passed => ("✓ Passed", Color::Green),
677        RalphStoryStatus::Failed => ("✗ Failed", Color::Red),
678    };
679
680    let header_lines = vec![
681        Line::from(vec![
682            Span::styled("Story: ", Style::default().fg(Color::DarkGray)),
683            Span::styled(
684                format!("{} — {}", story.id, story.title),
685                Style::default()
686                    .fg(Color::White)
687                    .add_modifier(Modifier::BOLD),
688            ),
689        ]),
690        Line::from(vec![
691            Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
692            Span::styled(status_text, Style::default().fg(status_color)),
693            Span::raw("  "),
694            Span::styled("Priority: ", Style::default().fg(Color::DarkGray)),
695            Span::styled(
696                format!("{}", story.priority),
697                Style::default().fg(Color::White),
698            ),
699            Span::raw("  "),
700            Span::styled("Steps: ", Style::default().fg(Color::DarkGray)),
701            Span::styled(
702                format!("{}", story.steps),
703                Style::default().fg(Color::White),
704            ),
705        ]),
706        Line::from(vec![
707            Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
708            Span::styled(
709                if story.depends_on.is_empty() {
710                    "none".to_string()
711                } else {
712                    story.depends_on.join(", ")
713                },
714                Style::default().fg(Color::DarkGray),
715            ),
716        ]),
717        // Quality checks row
718        Line::from({
719            let mut spans = vec![Span::styled(
720                "Quality: ",
721                Style::default().fg(Color::DarkGray),
722            )];
723            if story.quality_checks.is_empty() {
724                spans.push(Span::styled(
725                    "not run yet",
726                    Style::default().fg(Color::DarkGray),
727                ));
728            } else {
729                for (name, passed) in &story.quality_checks {
730                    let (icon, color) = if *passed {
731                        ("✓", Color::Green)
732                    } else {
733                        ("✗", Color::Red)
734                    };
735                    spans.push(Span::styled(
736                        format!("{}{} ", icon, name),
737                        Style::default().fg(color),
738                    ));
739                }
740            }
741            spans
742        }),
743    ];
744
745    let title = format!(" Story Detail: {} ", story.id);
746    let header = Paragraph::new(header_lines).block(
747        Block::default()
748            .borders(Borders::ALL)
749            .title(title)
750            .border_style(Style::default().fg(status_color)),
751    );
752    f.render_widget(header, chunks[0]);
753
754    // --- Content: tool history, messages, output, error ---
755    let mut content_lines: Vec<Line> = Vec::new();
756
757    // Tool call history
758    if !story.tool_call_history.is_empty() {
759        content_lines.push(Line::from(Span::styled(
760            "─── Tool Call History ───",
761            Style::default()
762                .fg(Color::Cyan)
763                .add_modifier(Modifier::BOLD),
764        )));
765        content_lines.push(Line::from(""));
766
767        for (i, tc) in story.tool_call_history.iter().enumerate() {
768            let icon = if tc.success { "✓" } else { "✗" };
769            let icon_color = if tc.success { Color::Green } else { Color::Red };
770            content_lines.push(Line::from(vec![
771                Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
772                Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::DarkGray)),
773                Span::styled(
774                    &tc.tool_name,
775                    Style::default()
776                        .fg(Color::Yellow)
777                        .add_modifier(Modifier::BOLD),
778                ),
779            ]));
780            if !tc.input_preview.is_empty() {
781                content_lines.push(Line::from(vec![
782                    Span::raw("     "),
783                    Span::styled("in: ", Style::default().fg(Color::DarkGray)),
784                    Span::styled(
785                        truncate_str(&tc.input_preview, 80),
786                        Style::default().fg(Color::White),
787                    ),
788                ]));
789            }
790            if !tc.output_preview.is_empty() {
791                content_lines.push(Line::from(vec![
792                    Span::raw("     "),
793                    Span::styled("out: ", Style::default().fg(Color::DarkGray)),
794                    Span::styled(
795                        truncate_str(&tc.output_preview, 80),
796                        Style::default().fg(Color::White),
797                    ),
798                ]));
799            }
800        }
801        content_lines.push(Line::from(""));
802    } else if story.steps > 0 {
803        content_lines.push(Line::from(Span::styled(
804            format!("─── {} tool calls (no detail captured) ───", story.steps),
805            Style::default().fg(Color::DarkGray),
806        )));
807        content_lines.push(Line::from(""));
808    }
809
810    // Messages
811    if !story.messages.is_empty() {
812        content_lines.push(Line::from(Span::styled(
813            "─── Conversation ───",
814            Style::default()
815                .fg(Color::Cyan)
816                .add_modifier(Modifier::BOLD),
817        )));
818        content_lines.push(Line::from(""));
819
820        for msg in &story.messages {
821            let (role_color, role_label) = match msg.role.as_str() {
822                "user" => (Color::White, "USER"),
823                "assistant" => (Color::Cyan, "ASST"),
824                "tool" => (Color::Yellow, "TOOL"),
825                "system" => (Color::DarkGray, "SYS "),
826                _ => (Color::White, "    "),
827            };
828            content_lines.push(Line::from(vec![Span::styled(
829                format!(" [{role_label}] "),
830                Style::default().fg(role_color).add_modifier(Modifier::BOLD),
831            )]));
832            for line in msg.content.lines().take(10) {
833                content_lines.push(Line::from(vec![
834                    Span::raw("   "),
835                    Span::styled(line, Style::default().fg(Color::White)),
836                ]));
837            }
838            if msg.content.lines().count() > 10 {
839                content_lines.push(Line::from(vec![
840                    Span::raw("   "),
841                    Span::styled("... (truncated)", Style::default().fg(Color::DarkGray)),
842                ]));
843            }
844            content_lines.push(Line::from(""));
845        }
846    }
847
848    // Output
849    if let Some(ref output) = story.output {
850        content_lines.push(Line::from(Span::styled(
851            "─── Output ───",
852            Style::default()
853                .fg(Color::Green)
854                .add_modifier(Modifier::BOLD),
855        )));
856        content_lines.push(Line::from(""));
857        for line in output.lines().take(20) {
858            content_lines.push(Line::from(Span::styled(
859                line,
860                Style::default().fg(Color::White),
861            )));
862        }
863        if output.lines().count() > 20 {
864            content_lines.push(Line::from(Span::styled(
865                "... (truncated)",
866                Style::default().fg(Color::DarkGray),
867            )));
868        }
869        content_lines.push(Line::from(""));
870    }
871
872    // Merge summary
873    if let Some(ref summary) = story.merge_summary {
874        content_lines.push(Line::from(Span::styled(
875            "─── Merge ───",
876            Style::default()
877                .fg(Color::Magenta)
878                .add_modifier(Modifier::BOLD),
879        )));
880        content_lines.push(Line::from(""));
881        content_lines.push(Line::from(Span::styled(
882            summary.as_str(),
883            Style::default().fg(Color::White),
884        )));
885        content_lines.push(Line::from(""));
886    }
887
888    // Error
889    if let Some(ref err) = story.error {
890        content_lines.push(Line::from(Span::styled(
891            "─── Error ───",
892            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
893        )));
894        content_lines.push(Line::from(""));
895        for line in err.lines() {
896            content_lines.push(Line::from(Span::styled(
897                line,
898                Style::default().fg(Color::Red),
899            )));
900        }
901        content_lines.push(Line::from(""));
902    }
903
904    // If nothing to show
905    if content_lines.is_empty() {
906        content_lines.push(Line::from(Span::styled(
907            "  Waiting for agent activity...",
908            Style::default().fg(Color::DarkGray),
909        )));
910    }
911
912    let content = Paragraph::new(content_lines)
913        .block(Block::default().borders(Borders::ALL))
914        .wrap(Wrap { trim: false })
915        .scroll((state.detail_scroll as u16, 0));
916    f.render_widget(content, chunks[1]);
917
918    // --- Key hints ---
919    let hints = Paragraph::new(Line::from(vec![
920        Span::styled(" Esc", Style::default().fg(Color::Yellow)),
921        Span::raw(": Back  "),
922        Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
923        Span::raw(": Scroll  "),
924        Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
925        Span::raw(": Prev/Next story"),
926    ]));
927    f.render_widget(hints, chunks[2]);
928}
929
930fn render_footer(f: &mut Frame, state: &RalphViewState, area: Rect) {
931    let content = if let Some(ref status) = state.final_status {
932        Line::from(vec![
933            Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
934            Span::styled(
935                status.as_str(),
936                Style::default().fg(if status.contains("Completed") {
937                    Color::Green
938                } else {
939                    Color::Yellow
940                }),
941            ),
942        ])
943    } else if let Some(ref err) = state.error {
944        Line::from(vec![Span::styled(
945            format!("Error: {}", err),
946            Style::default().fg(Color::Red),
947        )])
948    } else {
949        Line::from(vec![Span::styled(
950            "Executing...",
951            Style::default().fg(Color::DarkGray),
952        )])
953    };
954
955    let paragraph = Paragraph::new(content)
956        .block(Block::default().borders(Borders::ALL).title(" Status "))
957        .wrap(Wrap { trim: true });
958    f.render_widget(paragraph, area);
959}
960
961/// Truncate a string for display
962fn truncate_str(s: &str, max: usize) -> String {
963    if s.len() <= max {
964        s.replace('\n', " ")
965    } else {
966        let mut end = max;
967        while end > 0 && !s.is_char_boundary(end) {
968            end -= 1;
969        }
970        format!("{}...", s[..end].replace('\n', " "))
971    }
972}