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                    crate::tui::agent_detail_update::push(&mut story.tool_call_history, 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                    crate::tui::agent_detail_update::push(&mut story.messages, 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                    crate::tui::agent_detail_update::output(
327                        &mut story.output,
328                        &output,
329                        "Ralph output",
330                    );
331                }
332            }
333            RalphEvent::StoryError { story_id, error } => {
334                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
335                    story.error = Some(error.clone());
336                    story.status = RalphStoryStatus::Failed;
337                }
338            }
339            RalphEvent::StoryMerge {
340                story_id,
341                success: _,
342                summary,
343            } => {
344                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
345                    story.merge_summary = Some(summary);
346                }
347            }
348            RalphEvent::StageComplete { .. } => {
349                // Could track stage progress if needed
350            }
351            RalphEvent::Complete {
352                status,
353                passed: _,
354                total: _,
355            } => {
356                self.complete = true;
357                self.final_status = Some(status);
358            }
359            RalphEvent::Error(err) => {
360                self.error = Some(err);
361            }
362        }
363    }
364
365    /// Move selection up
366    pub fn select_prev(&mut self) {
367        if self.stories.is_empty() {
368            return;
369        }
370        self.selected_index = self.selected_index.saturating_sub(1);
371        self.list_state.select(Some(self.selected_index));
372    }
373
374    /// Move selection down
375    pub fn select_next(&mut self) {
376        if self.stories.is_empty() {
377            return;
378        }
379        self.selected_index = (self.selected_index + 1).min(self.stories.len() - 1);
380        self.list_state.select(Some(self.selected_index));
381    }
382
383    /// Enter detail mode for the selected story
384    pub fn enter_detail(&mut self) {
385        if !self.stories.is_empty() {
386            self.detail_mode = true;
387            self.detail_scroll = 0;
388        }
389    }
390
391    /// Exit detail mode
392    pub fn exit_detail(&mut self) {
393        self.detail_mode = false;
394        self.detail_scroll = 0;
395    }
396
397    /// Scroll detail view down
398    pub fn detail_scroll_down(&mut self, amount: usize) {
399        self.detail_scroll = self.detail_scroll.saturating_add(amount);
400    }
401
402    /// Scroll detail view up
403    pub fn detail_scroll_up(&mut self, amount: usize) {
404        self.detail_scroll = self.detail_scroll.saturating_sub(amount);
405    }
406
407    /// Get the currently selected story
408    pub fn selected_story(&self) -> Option<&RalphStoryInfo> {
409        self.stories.get(self.selected_index)
410    }
411
412    /// Status counts
413    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
414        let mut pending = 0;
415        let mut running = 0;
416        let mut passed = 0;
417        let mut failed = 0;
418        for story in &self.stories {
419            match story.status {
420                RalphStoryStatus::Pending | RalphStoryStatus::Blocked => pending += 1,
421                RalphStoryStatus::Running | RalphStoryStatus::QualityCheck => running += 1,
422                RalphStoryStatus::Passed => passed += 1,
423                RalphStoryStatus::Failed => failed += 1,
424            }
425        }
426        (pending, running, passed, failed)
427    }
428
429    /// Overall progress as percentage
430    pub fn progress(&self) -> f64 {
431        if self.stories.is_empty() {
432            return 0.0;
433        }
434        let (_, _, passed, failed) = self.status_counts();
435        ((passed + failed) as f64 / self.stories.len() as f64) * 100.0
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Rendering
441// ---------------------------------------------------------------------------
442
443/// Render the Ralph view
444pub fn render_ralph_view(f: &mut Frame, state: &mut RalphViewState, area: Rect) {
445    if state.detail_mode {
446        render_story_detail(f, state, area);
447        return;
448    }
449
450    let chunks = Layout::default()
451        .direction(Direction::Vertical)
452        .constraints([
453            Constraint::Length(4), // Header: project/feature + iteration
454            Constraint::Length(3), // Progress bar
455            Constraint::Min(1),    // Story list
456            Constraint::Length(3), // Footer / status
457        ])
458        .split(area);
459
460    render_header(f, state, chunks[0]);
461    render_progress(f, state, chunks[1]);
462    render_story_list(f, state, chunks[2]);
463    render_footer(f, state, chunks[3]);
464}
465
466fn render_header(f: &mut Frame, state: &RalphViewState, area: Rect) {
467    let (pending, running, passed, failed) = state.status_counts();
468    let total = state.stories.len();
469
470    let title = if state.complete {
471        if state.error.is_some() {
472            " Ralph [ERROR] "
473        } else if failed > 0 {
474            " Ralph [PARTIAL] "
475        } else {
476            " Ralph [COMPLETE] "
477        }
478    } else {
479        " Ralph [ACTIVE] "
480    };
481
482    let border_color = if state.complete {
483        if state.error.is_some() || failed > 0 {
484            Color::Red
485        } else {
486            Color::Green
487        }
488    } else {
489        Color::Magenta
490    };
491
492    let lines = vec![
493        Line::from(vec![
494            Span::styled("Project: ", Style::default().fg(Color::DarkGray)),
495            Span::styled(&state.project, Style::default().fg(Color::White)),
496            Span::raw("  "),
497            Span::styled("Feature: ", Style::default().fg(Color::DarkGray)),
498            Span::styled(&state.feature, Style::default().fg(Color::White)),
499        ]),
500        Line::from(vec![
501            Span::styled(
502                format!(
503                    "Iteration: {}/{}",
504                    state.current_iteration, state.max_iterations
505                ),
506                Style::default().fg(Color::Cyan),
507            ),
508            Span::raw("  "),
509            Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
510            Span::raw(" "),
511            Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
512            Span::raw(" "),
513            Span::styled(format!("✓{}", passed), Style::default().fg(Color::Green)),
514            Span::raw(" "),
515            Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
516            Span::raw(" "),
517            Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
518        ]),
519    ];
520
521    let paragraph = Paragraph::new(lines).block(
522        Block::default()
523            .borders(Borders::ALL)
524            .title(title)
525            .border_style(Style::default().fg(border_color)),
526    );
527    f.render_widget(paragraph, area);
528}
529
530fn render_progress(f: &mut Frame, state: &RalphViewState, area: Rect) {
531    let progress = state.progress();
532    let (_, _, passed, _) = state.status_counts();
533    let total = state.stories.len();
534    let label = format!("{}/{} stories — {:.0}%", passed, total, progress);
535
536    let gauge = Gauge::default()
537        .block(Block::default().borders(Borders::ALL).title(" Progress "))
538        .gauge_style(Style::default().fg(Color::Magenta).bg(Color::DarkGray))
539        .percent(progress as u16)
540        .label(label);
541
542    f.render_widget(gauge, area);
543}
544
545fn render_story_list(f: &mut Frame, state: &mut RalphViewState, area: Rect) {
546    state.list_state.select(Some(state.selected_index));
547
548    let items: Vec<ListItem> = state
549        .stories
550        .iter()
551        .map(|story| {
552            let (icon, color) = match story.status {
553                RalphStoryStatus::Pending => ("○", Color::DarkGray),
554                RalphStoryStatus::Blocked => ("⊘", Color::Yellow),
555                RalphStoryStatus::Running => ("●", Color::Cyan),
556                RalphStoryStatus::QualityCheck => ("◎", Color::Magenta),
557                RalphStoryStatus::Passed => ("✓", Color::Green),
558                RalphStoryStatus::Failed => ("✗", Color::Red),
559            };
560
561            let mut spans = vec![
562                Span::styled(format!("{} ", icon), Style::default().fg(color)),
563                Span::styled(
564                    format!("[{}] ", story.id),
565                    Style::default().fg(Color::DarkGray),
566                ),
567                Span::styled(&story.title, Style::default().fg(Color::White)),
568            ];
569
570            // Show current tool for running stories
571            if story.status == RalphStoryStatus::Running {
572                if let Some(ref tool) = story.current_tool {
573                    spans.push(Span::styled(
574                        format!(" → {}", tool),
575                        Style::default()
576                            .fg(Color::Yellow)
577                            .add_modifier(Modifier::DIM),
578                    ));
579                }
580                if story.steps > 0 {
581                    spans.push(Span::styled(
582                        format!(" (step {})", story.steps),
583                        Style::default().fg(Color::DarkGray),
584                    ));
585                }
586            }
587
588            // Show quality check status
589            if story.status == RalphStoryStatus::QualityCheck {
590                let qc_summary: Vec<&str> = story
591                    .quality_checks
592                    .iter()
593                    .map(|(name, _passed)| name.as_str())
594                    .collect();
595                if !qc_summary.is_empty() {
596                    let checks: String = story
597                        .quality_checks
598                        .iter()
599                        .map(|(name, passed)| {
600                            if *passed {
601                                format!("✓{}", name)
602                            } else {
603                                format!("✗{}", name)
604                            }
605                        })
606                        .collect::<Vec<_>>()
607                        .join(" ");
608                    spans.push(Span::styled(
609                        format!(" [{}]", checks),
610                        Style::default().fg(Color::Magenta),
611                    ));
612                }
613            }
614
615            // Show passed quality checks for completed stories
616            if story.status == RalphStoryStatus::Passed && !story.quality_checks.is_empty() {
617                let all_passed = story.quality_checks.iter().all(|(_, p)| *p);
618                if all_passed {
619                    spans.push(Span::styled(
620                        " ✓QC",
621                        Style::default()
622                            .fg(Color::Green)
623                            .add_modifier(Modifier::DIM),
624                    ));
625                }
626            }
627
628            ListItem::new(Line::from(spans))
629        })
630        .collect();
631
632    let title = if state.stories.is_empty() {
633        " Stories (loading...) "
634    } else {
635        " Stories (↑↓:select  Enter:detail) "
636    };
637
638    let list = List::new(items)
639        .block(Block::default().borders(Borders::ALL).title(title))
640        .highlight_style(
641            Style::default()
642                .add_modifier(Modifier::BOLD)
643                .bg(Color::DarkGray),
644        )
645        .highlight_symbol("▶ ");
646
647    f.render_stateful_widget(list, area, &mut state.list_state);
648}
649
650/// Render full-screen detail view for the selected story's sub-agent
651fn render_story_detail(f: &mut Frame, state: &RalphViewState, area: Rect) {
652    let story = match state.selected_story() {
653        Some(s) => s,
654        None => {
655            let p = Paragraph::new("No story selected").block(
656                Block::default()
657                    .borders(Borders::ALL)
658                    .title(" Story Detail "),
659            );
660            f.render_widget(p, area);
661            return;
662        }
663    };
664
665    let chunks = Layout::default()
666        .direction(Direction::Vertical)
667        .constraints([
668            Constraint::Length(6), // Story info header
669            Constraint::Min(1),    // Content
670            Constraint::Length(1), // Key hints
671        ])
672        .split(area);
673
674    // --- Header ---
675    let (status_text, status_color) = match story.status {
676        RalphStoryStatus::Pending => ("○ Pending", Color::DarkGray),
677        RalphStoryStatus::Blocked => ("⊘ Blocked", Color::Yellow),
678        RalphStoryStatus::Running => ("● Running", Color::Cyan),
679        RalphStoryStatus::QualityCheck => ("◎ Quality Check", Color::Magenta),
680        RalphStoryStatus::Passed => ("✓ Passed", Color::Green),
681        RalphStoryStatus::Failed => ("✗ Failed", Color::Red),
682    };
683
684    let header_lines = vec![
685        Line::from(vec![
686            Span::styled("Story: ", Style::default().fg(Color::DarkGray)),
687            Span::styled(
688                format!("{} — {}", story.id, story.title),
689                Style::default()
690                    .fg(Color::White)
691                    .add_modifier(Modifier::BOLD),
692            ),
693        ]),
694        Line::from(vec![
695            Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
696            Span::styled(status_text, Style::default().fg(status_color)),
697            Span::raw("  "),
698            Span::styled("Priority: ", Style::default().fg(Color::DarkGray)),
699            Span::styled(
700                format!("{}", story.priority),
701                Style::default().fg(Color::White),
702            ),
703            Span::raw("  "),
704            Span::styled("Steps: ", Style::default().fg(Color::DarkGray)),
705            Span::styled(
706                format!("{}", story.steps),
707                Style::default().fg(Color::White),
708            ),
709        ]),
710        Line::from(vec![
711            Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
712            Span::styled(
713                if story.depends_on.is_empty() {
714                    "none".to_string()
715                } else {
716                    story.depends_on.join(", ")
717                },
718                Style::default().fg(Color::DarkGray),
719            ),
720        ]),
721        // Quality checks row
722        Line::from({
723            let mut spans = vec![Span::styled(
724                "Quality: ",
725                Style::default().fg(Color::DarkGray),
726            )];
727            if story.quality_checks.is_empty() {
728                spans.push(Span::styled(
729                    "not run yet",
730                    Style::default().fg(Color::DarkGray),
731                ));
732            } else {
733                for (name, passed) in &story.quality_checks {
734                    let (icon, color) = if *passed {
735                        ("✓", Color::Green)
736                    } else {
737                        ("✗", Color::Red)
738                    };
739                    spans.push(Span::styled(
740                        format!("{}{} ", icon, name),
741                        Style::default().fg(color),
742                    ));
743                }
744            }
745            spans
746        }),
747    ];
748
749    let title = format!(" Story Detail: {} ", story.id);
750    let header = Paragraph::new(header_lines).block(
751        Block::default()
752            .borders(Borders::ALL)
753            .title(title)
754            .border_style(Style::default().fg(status_color)),
755    );
756    f.render_widget(header, chunks[0]);
757
758    // --- Content: tool history, messages, output, error ---
759    let mut content_lines: Vec<Line> = Vec::new();
760
761    // Tool call history
762    if !story.tool_call_history.is_empty() {
763        content_lines.push(Line::from(Span::styled(
764            "─── Tool Call History ───",
765            Style::default()
766                .fg(Color::Cyan)
767                .add_modifier(Modifier::BOLD),
768        )));
769        content_lines.push(Line::from(""));
770
771        for (i, tc) in story.tool_call_history.iter().enumerate() {
772            let icon = if tc.success { "✓" } else { "✗" };
773            let icon_color = if tc.success { Color::Green } else { Color::Red };
774            content_lines.push(Line::from(vec![
775                Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
776                Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::DarkGray)),
777                Span::styled(
778                    &tc.tool_name,
779                    Style::default()
780                        .fg(Color::Yellow)
781                        .add_modifier(Modifier::BOLD),
782                ),
783            ]));
784            if !tc.input_preview.is_empty() {
785                content_lines.push(Line::from(vec![
786                    Span::raw("     "),
787                    Span::styled("in: ", Style::default().fg(Color::DarkGray)),
788                    Span::styled(
789                        truncate_str(&tc.input_preview, 80),
790                        Style::default().fg(Color::White),
791                    ),
792                ]));
793            }
794            if !tc.output_preview.is_empty() {
795                content_lines.push(Line::from(vec![
796                    Span::raw("     "),
797                    Span::styled("out: ", Style::default().fg(Color::DarkGray)),
798                    Span::styled(
799                        truncate_str(&tc.output_preview, 80),
800                        Style::default().fg(Color::White),
801                    ),
802                ]));
803            }
804        }
805        content_lines.push(Line::from(""));
806    } else if story.steps > 0 {
807        content_lines.push(Line::from(Span::styled(
808            format!("─── {} tool calls (no detail captured) ───", story.steps),
809            Style::default().fg(Color::DarkGray),
810        )));
811        content_lines.push(Line::from(""));
812    }
813
814    // Messages
815    if !story.messages.is_empty() {
816        content_lines.push(Line::from(Span::styled(
817            "─── Conversation ───",
818            Style::default()
819                .fg(Color::Cyan)
820                .add_modifier(Modifier::BOLD),
821        )));
822        content_lines.push(Line::from(""));
823
824        for msg in &story.messages {
825            let (role_color, role_label) = match msg.role.as_str() {
826                "user" => (Color::White, "USER"),
827                "assistant" => (Color::Cyan, "ASST"),
828                "tool" => (Color::Yellow, "TOOL"),
829                "system" => (Color::DarkGray, "SYS "),
830                _ => (Color::White, "    "),
831            };
832            content_lines.push(Line::from(vec![Span::styled(
833                format!(" [{role_label}] "),
834                Style::default().fg(role_color).add_modifier(Modifier::BOLD),
835            )]));
836            for line in msg.content.lines().take(10) {
837                content_lines.push(Line::from(vec![
838                    Span::raw("   "),
839                    Span::styled(line, Style::default().fg(Color::White)),
840                ]));
841            }
842            if msg.content.lines().count() > 10 {
843                content_lines.push(Line::from(vec![
844                    Span::raw("   "),
845                    Span::styled("... (truncated)", Style::default().fg(Color::DarkGray)),
846                ]));
847            }
848            content_lines.push(Line::from(""));
849        }
850    }
851
852    // Output
853    if let Some(ref output) = story.output {
854        content_lines.push(Line::from(Span::styled(
855            "─── Output ───",
856            Style::default()
857                .fg(Color::Green)
858                .add_modifier(Modifier::BOLD),
859        )));
860        content_lines.push(Line::from(""));
861        for line in output.lines().take(20) {
862            content_lines.push(Line::from(Span::styled(
863                line,
864                Style::default().fg(Color::White),
865            )));
866        }
867        if output.lines().count() > 20 {
868            content_lines.push(Line::from(Span::styled(
869                "... (truncated)",
870                Style::default().fg(Color::DarkGray),
871            )));
872        }
873        content_lines.push(Line::from(""));
874    }
875
876    // Merge summary
877    if let Some(ref summary) = story.merge_summary {
878        content_lines.push(Line::from(Span::styled(
879            "─── Merge ───",
880            Style::default()
881                .fg(Color::Magenta)
882                .add_modifier(Modifier::BOLD),
883        )));
884        content_lines.push(Line::from(""));
885        content_lines.push(Line::from(Span::styled(
886            summary.as_str(),
887            Style::default().fg(Color::White),
888        )));
889        content_lines.push(Line::from(""));
890    }
891
892    // Error
893    if let Some(ref err) = story.error {
894        content_lines.push(Line::from(Span::styled(
895            "─── Error ───",
896            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
897        )));
898        content_lines.push(Line::from(""));
899        for line in err.lines() {
900            content_lines.push(Line::from(Span::styled(
901                line,
902                Style::default().fg(Color::Red),
903            )));
904        }
905        content_lines.push(Line::from(""));
906    }
907
908    // If nothing to show
909    if content_lines.is_empty() {
910        content_lines.push(Line::from(Span::styled(
911            "  Waiting for agent activity...",
912            Style::default().fg(Color::DarkGray),
913        )));
914    }
915
916    let content = Paragraph::new(content_lines)
917        .block(Block::default().borders(Borders::ALL))
918        .wrap(Wrap { trim: false })
919        .scroll((state.detail_scroll as u16, 0));
920    f.render_widget(content, chunks[1]);
921
922    // --- Key hints ---
923    let hints = Paragraph::new(Line::from(vec![
924        Span::styled(" Esc", Style::default().fg(Color::Yellow)),
925        Span::raw(": Back  "),
926        Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
927        Span::raw(": Scroll  "),
928        Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
929        Span::raw(": Prev/Next story"),
930    ]));
931    f.render_widget(hints, chunks[2]);
932}
933
934fn render_footer(f: &mut Frame, state: &RalphViewState, area: Rect) {
935    let content = if let Some(ref status) = state.final_status {
936        Line::from(vec![
937            Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
938            Span::styled(
939                status.as_str(),
940                Style::default().fg(if status.contains("Completed") {
941                    Color::Green
942                } else {
943                    Color::Yellow
944                }),
945            ),
946        ])
947    } else if let Some(ref err) = state.error {
948        Line::from(vec![Span::styled(
949            format!("Error: {}", err),
950            Style::default().fg(Color::Red),
951        )])
952    } else {
953        Line::from(vec![Span::styled(
954            "Executing...",
955            Style::default().fg(Color::DarkGray),
956        )])
957    };
958
959    let paragraph = Paragraph::new(content)
960        .block(Block::default().borders(Borders::ALL).title(" Status "))
961        .wrap(Wrap { trim: true });
962    f.render_widget(paragraph, area);
963}
964
965/// Truncate a string for display
966fn truncate_str(s: &str, max: usize) -> String {
967    if s.len() <= max {
968        s.replace('\n', " ")
969    } else {
970        let mut end = max;
971        while end > 0 && !s.is_char_boundary(end) {
972            end -= 1;
973        }
974        format!("{}...", s[..end].replace('\n', " "))
975    }
976}