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