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