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