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};
14
15#[derive(Debug, Clone)]
21pub enum RalphEvent {
22 Started {
24 project: String,
25 feature: String,
26 stories: Vec<RalphStoryInfo>,
27 max_iterations: usize,
28 },
29
30 IterationStarted {
32 iteration: usize,
33 max_iterations: usize,
34 },
35
36 StoryStarted { story_id: String },
38
39 StoryToolCall { story_id: String, tool_name: String },
41
42 StoryToolCallDetail {
44 story_id: String,
45 detail: AgentToolCallDetail,
46 },
47
48 StoryMessage {
50 story_id: String,
51 entry: AgentMessageEntry,
52 },
53
54 StoryQualityCheck {
56 story_id: String,
57 check_name: String,
58 passed: bool,
59 },
60
61 StoryComplete { story_id: String, passed: bool },
63
64 StoryOutput { story_id: String, output: String },
66
67 StoryError { story_id: String, error: String },
69
70 StoryMerge {
72 story_id: String,
73 success: bool,
74 summary: String,
75 },
76
77 StageComplete {
79 stage: usize,
80 completed: usize,
81 failed: usize,
82 },
83
84 Complete {
86 status: String,
87 passed: usize,
88 total: usize,
89 },
90
91 Error(String),
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum RalphStoryStatus {
102 Pending,
103 Blocked,
104 Running,
105 QualityCheck,
106 Passed,
107 Failed,
108}
109
110#[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 pub quality_checks: Vec<(String, bool)>,
124 pub tool_call_history: Vec<AgentToolCallDetail>,
126 pub messages: Vec<AgentMessageEntry>,
128 pub output: Option<String>,
130 pub error: Option<String>,
132 pub merge_summary: Option<String>,
134 pub steps: usize,
136 pub current_tool: Option<String>,
138}
139
140#[derive(Debug)]
146pub struct RalphViewState {
147 pub active: bool,
149 pub project: String,
151 pub feature: String,
153 pub stories: Vec<RalphStoryInfo>,
155 pub current_iteration: usize,
157 pub max_iterations: usize,
159 pub complete: bool,
161 pub final_status: Option<String>,
163 pub error: Option<String>,
165 pub selected_index: usize,
167 pub detail_mode: bool,
169 pub detail_scroll: usize,
171 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 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 }
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 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 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 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 pub fn exit_detail(&mut self) {
352 self.detail_mode = false;
353 self.detail_scroll = 0;
354 }
355
356 pub fn detail_scroll_down(&mut self, amount: usize) {
358 self.detail_scroll = self.detail_scroll.saturating_add(amount);
359 }
360
361 pub fn detail_scroll_up(&mut self, amount: usize) {
363 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
364 }
365
366 pub fn selected_story(&self) -> Option<&RalphStoryInfo> {
368 self.stories.get(self.selected_index)
369 }
370
371 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 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
398pub 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), Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ])
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 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 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 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
615fn 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), Constraint::Min(1), Constraint::Length(1), ])
637 .split(area);
638
639 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 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 let mut content_lines: Vec<Line> = Vec::new();
725
726 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 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 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 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 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 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 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
930fn 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}