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)]
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 Default for RalphViewState {
176    fn default() -> Self {
177        Self {
178            active: false,
179            project: String::new(),
180            feature: String::new(),
181            stories: Vec::new(),
182            current_iteration: 0,
183            max_iterations: 0,
184            complete: false,
185            final_status: None,
186            error: None,
187            selected_index: 0,
188            detail_mode: false,
189            detail_scroll: 0,
190            list_state: ListState::default(),
191        }
192    }
193}
194
195impl RalphViewState {
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Handle a Ralph event
201    pub fn handle_event(&mut self, event: RalphEvent) {
202        match event {
203            RalphEvent::Started {
204                project,
205                feature,
206                stories,
207                max_iterations,
208            } => {
209                self.active = true;
210                self.project = project;
211                self.feature = feature;
212                self.stories = stories;
213                self.max_iterations = max_iterations;
214                self.current_iteration = 0;
215                self.complete = false;
216                self.final_status = None;
217                self.error = None;
218                self.selected_index = 0;
219                self.detail_mode = false;
220                self.detail_scroll = 0;
221                self.list_state = ListState::default();
222                if !self.stories.is_empty() {
223                    self.list_state.select(Some(0));
224                }
225            }
226            RalphEvent::IterationStarted {
227                iteration,
228                max_iterations,
229            } => {
230                self.current_iteration = iteration;
231                self.max_iterations = max_iterations;
232            }
233            RalphEvent::StoryStarted { story_id } => {
234                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
235                    story.status = RalphStoryStatus::Running;
236                    story.steps = 0;
237                    story.current_tool = None;
238                    story.quality_checks.clear();
239                    story.tool_call_history.clear();
240                    story.messages.clear();
241                    story.output = None;
242                    story.error = None;
243                }
244            }
245            RalphEvent::StoryToolCall {
246                story_id,
247                tool_name,
248            } => {
249                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
250                    story.current_tool = Some(tool_name);
251                    story.steps += 1;
252                }
253            }
254            RalphEvent::StoryToolCallDetail { story_id, detail } => {
255                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
256                    story.current_tool = Some(detail.tool_name.clone());
257                    story.steps += 1;
258                    story.tool_call_history.push(detail);
259                }
260            }
261            RalphEvent::StoryMessage { story_id, entry } => {
262                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
263                    story.messages.push(entry);
264                }
265            }
266            RalphEvent::StoryQualityCheck {
267                story_id,
268                check_name,
269                passed,
270            } => {
271                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
272                    story.status = RalphStoryStatus::QualityCheck;
273                    story.current_tool = None;
274                    story.quality_checks.push((check_name, passed));
275                }
276            }
277            RalphEvent::StoryComplete { story_id, passed } => {
278                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
279                    story.status = if passed {
280                        RalphStoryStatus::Passed
281                    } else {
282                        RalphStoryStatus::Failed
283                    };
284                    story.current_tool = None;
285                }
286            }
287            RalphEvent::StoryOutput { story_id, output } => {
288                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
289                    story.output = Some(output);
290                }
291            }
292            RalphEvent::StoryError { story_id, error } => {
293                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
294                    story.error = Some(error.clone());
295                    story.status = RalphStoryStatus::Failed;
296                }
297            }
298            RalphEvent::StoryMerge {
299                story_id,
300                success: _,
301                summary,
302            } => {
303                if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
304                    story.merge_summary = Some(summary);
305                }
306            }
307            RalphEvent::StageComplete { .. } => {
308                // Could track stage progress if needed
309            }
310            RalphEvent::Complete {
311                status,
312                passed: _,
313                total: _,
314            } => {
315                self.complete = true;
316                self.final_status = Some(status);
317            }
318            RalphEvent::Error(err) => {
319                self.error = Some(err);
320            }
321        }
322    }
323
324    /// Move selection up
325    pub fn select_prev(&mut self) {
326        if self.stories.is_empty() {
327            return;
328        }
329        self.selected_index = self.selected_index.saturating_sub(1);
330        self.list_state.select(Some(self.selected_index));
331    }
332
333    /// Move selection down
334    pub fn select_next(&mut self) {
335        if self.stories.is_empty() {
336            return;
337        }
338        self.selected_index = (self.selected_index + 1).min(self.stories.len() - 1);
339        self.list_state.select(Some(self.selected_index));
340    }
341
342    /// Enter detail mode for the selected story
343    pub fn enter_detail(&mut self) {
344        if !self.stories.is_empty() {
345            self.detail_mode = true;
346            self.detail_scroll = 0;
347        }
348    }
349
350    /// Exit detail mode
351    pub fn exit_detail(&mut self) {
352        self.detail_mode = false;
353        self.detail_scroll = 0;
354    }
355
356    /// Scroll detail view down
357    pub fn detail_scroll_down(&mut self, amount: usize) {
358        self.detail_scroll = self.detail_scroll.saturating_add(amount);
359    }
360
361    /// Scroll detail view up
362    pub fn detail_scroll_up(&mut self, amount: usize) {
363        self.detail_scroll = self.detail_scroll.saturating_sub(amount);
364    }
365
366    /// Get the currently selected story
367    pub fn selected_story(&self) -> Option<&RalphStoryInfo> {
368        self.stories.get(self.selected_index)
369    }
370
371    /// Status counts
372    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
373        let mut pending = 0;
374        let mut running = 0;
375        let mut passed = 0;
376        let mut failed = 0;
377        for story in &self.stories {
378            match story.status {
379                RalphStoryStatus::Pending | RalphStoryStatus::Blocked => pending += 1,
380                RalphStoryStatus::Running | RalphStoryStatus::QualityCheck => running += 1,
381                RalphStoryStatus::Passed => passed += 1,
382                RalphStoryStatus::Failed => failed += 1,
383            }
384        }
385        (pending, running, passed, failed)
386    }
387
388    /// Overall progress as percentage
389    pub fn progress(&self) -> f64 {
390        if self.stories.is_empty() {
391            return 0.0;
392        }
393        let (_, _, passed, failed) = self.status_counts();
394        ((passed + failed) as f64 / self.stories.len() as f64) * 100.0
395    }
396}
397
398// ---------------------------------------------------------------------------
399// Rendering
400// ---------------------------------------------------------------------------
401
402/// Render the Ralph view
403pub fn render_ralph_view(f: &mut Frame, state: &mut RalphViewState, area: Rect) {
404    if state.detail_mode {
405        render_story_detail(f, state, area);
406        return;
407    }
408
409    let chunks = Layout::default()
410        .direction(Direction::Vertical)
411        .constraints([
412            Constraint::Length(4), // Header: project/feature + iteration
413            Constraint::Length(3), // Progress bar
414            Constraint::Min(1),    // Story list
415            Constraint::Length(3), // Footer / status
416        ])
417        .split(area);
418
419    render_header(f, state, chunks[0]);
420    render_progress(f, state, chunks[1]);
421    render_story_list(f, state, chunks[2]);
422    render_footer(f, state, chunks[3]);
423}
424
425fn render_header(f: &mut Frame, state: &RalphViewState, area: Rect) {
426    let (pending, running, passed, failed) = state.status_counts();
427    let total = state.stories.len();
428
429    let title = if state.complete {
430        if state.error.is_some() {
431            " Ralph [ERROR] "
432        } else if failed > 0 {
433            " Ralph [PARTIAL] "
434        } else {
435            " Ralph [COMPLETE] "
436        }
437    } else {
438        " Ralph [ACTIVE] "
439    };
440
441    let border_color = if state.complete {
442        if state.error.is_some() || failed > 0 {
443            Color::Red
444        } else {
445            Color::Green
446        }
447    } else {
448        Color::Magenta
449    };
450
451    let lines = vec![
452        Line::from(vec![
453            Span::styled("Project: ", Style::default().fg(Color::DarkGray)),
454            Span::styled(&state.project, Style::default().fg(Color::White)),
455            Span::raw("  "),
456            Span::styled("Feature: ", Style::default().fg(Color::DarkGray)),
457            Span::styled(&state.feature, Style::default().fg(Color::White)),
458        ]),
459        Line::from(vec![
460            Span::styled(
461                format!(
462                    "Iteration: {}/{}",
463                    state.current_iteration, state.max_iterations
464                ),
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}