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