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 {
41 story_id: String,
42 tool_name: String,
43 },
44
45 StoryToolCallDetail {
47 story_id: String,
48 detail: AgentToolCallDetail,
49 },
50
51 StoryMessage {
53 story_id: String,
54 entry: AgentMessageEntry,
55 },
56
57 StoryQualityCheck {
59 story_id: String,
60 check_name: String,
61 passed: bool,
62 },
63
64 StoryComplete { story_id: String, passed: bool },
66
67 StoryOutput { story_id: String, output: String },
69
70 StoryError { story_id: String, error: String },
72
73 StoryMerge {
75 story_id: String,
76 success: bool,
77 summary: String,
78 },
79
80 StageComplete {
82 stage: usize,
83 completed: usize,
84 failed: usize,
85 },
86
87 Complete {
89 status: String,
90 passed: usize,
91 total: usize,
92 },
93
94 Error(String),
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum RalphStoryStatus {
105 Pending,
106 Blocked,
107 Running,
108 QualityCheck,
109 Passed,
110 Failed,
111}
112
113#[derive(Debug, Clone)]
119pub struct RalphStoryInfo {
120 pub id: String,
121 pub title: String,
122 pub status: RalphStoryStatus,
123 pub priority: u8,
124 pub depends_on: Vec<String>,
125 pub quality_checks: Vec<(String, bool)>,
127 pub tool_call_history: Vec<AgentToolCallDetail>,
129 pub messages: Vec<AgentMessageEntry>,
131 pub output: Option<String>,
133 pub error: Option<String>,
135 pub merge_summary: Option<String>,
137 pub steps: usize,
139 pub current_tool: Option<String>,
141}
142
143#[derive(Debug)]
149pub struct RalphViewState {
150 pub active: bool,
152 pub project: String,
154 pub feature: String,
156 pub stories: Vec<RalphStoryInfo>,
158 pub current_iteration: usize,
160 pub max_iterations: usize,
162 pub complete: bool,
164 pub final_status: Option<String>,
166 pub error: Option<String>,
168 pub selected_index: usize,
170 pub detail_mode: bool,
172 pub detail_scroll: usize,
174 pub list_state: ListState,
176}
177
178impl Default for RalphViewState {
179 fn default() -> Self {
180 Self {
181 active: false,
182 project: String::new(),
183 feature: String::new(),
184 stories: Vec::new(),
185 current_iteration: 0,
186 max_iterations: 0,
187 complete: false,
188 final_status: None,
189 error: None,
190 selected_index: 0,
191 detail_mode: false,
192 detail_scroll: 0,
193 list_state: ListState::default(),
194 }
195 }
196}
197
198impl RalphViewState {
199 pub fn new() -> Self {
200 Self::default()
201 }
202
203 pub fn handle_event(&mut self, event: RalphEvent) {
205 match event {
206 RalphEvent::Started {
207 project,
208 feature,
209 stories,
210 max_iterations,
211 } => {
212 self.active = true;
213 self.project = project;
214 self.feature = feature;
215 self.stories = stories;
216 self.max_iterations = max_iterations;
217 self.current_iteration = 0;
218 self.complete = false;
219 self.final_status = None;
220 self.error = None;
221 self.selected_index = 0;
222 self.detail_mode = false;
223 self.detail_scroll = 0;
224 self.list_state = ListState::default();
225 if !self.stories.is_empty() {
226 self.list_state.select(Some(0));
227 }
228 }
229 RalphEvent::IterationStarted {
230 iteration,
231 max_iterations,
232 } => {
233 self.current_iteration = iteration;
234 self.max_iterations = max_iterations;
235 }
236 RalphEvent::StoryStarted { story_id } => {
237 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
238 story.status = RalphStoryStatus::Running;
239 story.steps = 0;
240 story.current_tool = None;
241 story.quality_checks.clear();
242 story.tool_call_history.clear();
243 story.messages.clear();
244 story.output = None;
245 story.error = None;
246 }
247 }
248 RalphEvent::StoryToolCall {
249 story_id,
250 tool_name,
251 } => {
252 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
253 story.current_tool = Some(tool_name);
254 story.steps += 1;
255 }
256 }
257 RalphEvent::StoryToolCallDetail { story_id, detail } => {
258 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
259 story.current_tool = Some(detail.tool_name.clone());
260 story.steps += 1;
261 story.tool_call_history.push(detail);
262 }
263 }
264 RalphEvent::StoryMessage { story_id, entry } => {
265 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
266 story.messages.push(entry);
267 }
268 }
269 RalphEvent::StoryQualityCheck {
270 story_id,
271 check_name,
272 passed,
273 } => {
274 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
275 story.status = RalphStoryStatus::QualityCheck;
276 story.current_tool = None;
277 story.quality_checks.push((check_name, passed));
278 }
279 }
280 RalphEvent::StoryComplete { story_id, passed } => {
281 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
282 story.status = if passed {
283 RalphStoryStatus::Passed
284 } else {
285 RalphStoryStatus::Failed
286 };
287 story.current_tool = None;
288 }
289 }
290 RalphEvent::StoryOutput { story_id, output } => {
291 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
292 story.output = Some(output);
293 }
294 }
295 RalphEvent::StoryError { story_id, error } => {
296 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
297 story.error = Some(error.clone());
298 story.status = RalphStoryStatus::Failed;
299 }
300 }
301 RalphEvent::StoryMerge {
302 story_id,
303 success: _,
304 summary,
305 } => {
306 if let Some(story) = self.stories.iter_mut().find(|s| s.id == story_id) {
307 story.merge_summary = Some(summary);
308 }
309 }
310 RalphEvent::StageComplete { .. } => {
311 }
313 RalphEvent::Complete {
314 status,
315 passed: _,
316 total: _,
317 } => {
318 self.complete = true;
319 self.final_status = Some(status);
320 }
321 RalphEvent::Error(err) => {
322 self.error = Some(err);
323 }
324 }
325 }
326
327 pub fn select_prev(&mut self) {
329 if self.stories.is_empty() {
330 return;
331 }
332 self.selected_index = self.selected_index.saturating_sub(1);
333 self.list_state.select(Some(self.selected_index));
334 }
335
336 pub fn select_next(&mut self) {
338 if self.stories.is_empty() {
339 return;
340 }
341 self.selected_index = (self.selected_index + 1).min(self.stories.len() - 1);
342 self.list_state.select(Some(self.selected_index));
343 }
344
345 pub fn enter_detail(&mut self) {
347 if !self.stories.is_empty() {
348 self.detail_mode = true;
349 self.detail_scroll = 0;
350 }
351 }
352
353 pub fn exit_detail(&mut self) {
355 self.detail_mode = false;
356 self.detail_scroll = 0;
357 }
358
359 pub fn detail_scroll_down(&mut self, amount: usize) {
361 self.detail_scroll = self.detail_scroll.saturating_add(amount);
362 }
363
364 pub fn detail_scroll_up(&mut self, amount: usize) {
366 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
367 }
368
369 pub fn selected_story(&self) -> Option<&RalphStoryInfo> {
371 self.stories.get(self.selected_index)
372 }
373
374 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
376 let mut pending = 0;
377 let mut running = 0;
378 let mut passed = 0;
379 let mut failed = 0;
380 for story in &self.stories {
381 match story.status {
382 RalphStoryStatus::Pending | RalphStoryStatus::Blocked => pending += 1,
383 RalphStoryStatus::Running | RalphStoryStatus::QualityCheck => running += 1,
384 RalphStoryStatus::Passed => passed += 1,
385 RalphStoryStatus::Failed => failed += 1,
386 }
387 }
388 (pending, running, passed, failed)
389 }
390
391 pub fn progress(&self) -> f64 {
393 if self.stories.is_empty() {
394 return 0.0;
395 }
396 let (_, _, passed, failed) = self.status_counts();
397 ((passed + failed) as f64 / self.stories.len() as f64) * 100.0
398 }
399}
400
401pub fn render_ralph_view(f: &mut Frame, state: &mut RalphViewState, area: Rect) {
407 if state.detail_mode {
408 render_story_detail(f, state, area);
409 return;
410 }
411
412 let chunks = Layout::default()
413 .direction(Direction::Vertical)
414 .constraints([
415 Constraint::Length(4), Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ])
420 .split(area);
421
422 render_header(f, state, chunks[0]);
423 render_progress(f, state, chunks[1]);
424 render_story_list(f, state, chunks[2]);
425 render_footer(f, state, chunks[3]);
426}
427
428fn render_header(f: &mut Frame, state: &RalphViewState, area: Rect) {
429 let (pending, running, passed, failed) = state.status_counts();
430 let total = state.stories.len();
431
432 let title = if state.complete {
433 if state.error.is_some() {
434 " Ralph [ERROR] "
435 } else if failed > 0 {
436 " Ralph [PARTIAL] "
437 } else {
438 " Ralph [COMPLETE] "
439 }
440 } else {
441 " Ralph [ACTIVE] "
442 };
443
444 let border_color = if state.complete {
445 if state.error.is_some() || failed > 0 {
446 Color::Red
447 } else {
448 Color::Green
449 }
450 } else {
451 Color::Magenta
452 };
453
454 let lines = vec![
455 Line::from(vec![
456 Span::styled("Project: ", Style::default().fg(Color::DarkGray)),
457 Span::styled(&state.project, Style::default().fg(Color::White)),
458 Span::raw(" "),
459 Span::styled("Feature: ", Style::default().fg(Color::DarkGray)),
460 Span::styled(&state.feature, Style::default().fg(Color::White)),
461 ]),
462 Line::from(vec![
463 Span::styled(
464 format!("Iteration: {}/{}", state.current_iteration, state.max_iterations),
465 Style::default().fg(Color::Cyan),
466 ),
467 Span::raw(" "),
468 Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
469 Span::raw(" "),
470 Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
471 Span::raw(" "),
472 Span::styled(format!("✓{}", passed), Style::default().fg(Color::Green)),
473 Span::raw(" "),
474 Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
475 Span::raw(" "),
476 Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
477 ]),
478 ];
479
480 let paragraph = Paragraph::new(lines).block(
481 Block::default()
482 .borders(Borders::ALL)
483 .title(title)
484 .border_style(Style::default().fg(border_color)),
485 );
486 f.render_widget(paragraph, area);
487}
488
489fn render_progress(f: &mut Frame, state: &RalphViewState, area: Rect) {
490 let progress = state.progress();
491 let (_, _, passed, _) = state.status_counts();
492 let total = state.stories.len();
493 let label = format!("{}/{} stories — {:.0}%", passed, total, progress);
494
495 let gauge = Gauge::default()
496 .block(Block::default().borders(Borders::ALL).title(" Progress "))
497 .gauge_style(Style::default().fg(Color::Magenta).bg(Color::DarkGray))
498 .percent(progress as u16)
499 .label(label);
500
501 f.render_widget(gauge, area);
502}
503
504fn render_story_list(f: &mut Frame, state: &mut RalphViewState, area: Rect) {
505 state.list_state.select(Some(state.selected_index));
506
507 let items: Vec<ListItem> = state
508 .stories
509 .iter()
510 .map(|story| {
511 let (icon, color) = match story.status {
512 RalphStoryStatus::Pending => ("○", Color::DarkGray),
513 RalphStoryStatus::Blocked => ("⊘", Color::Yellow),
514 RalphStoryStatus::Running => ("●", Color::Cyan),
515 RalphStoryStatus::QualityCheck => ("◎", Color::Magenta),
516 RalphStoryStatus::Passed => ("✓", Color::Green),
517 RalphStoryStatus::Failed => ("✗", Color::Red),
518 };
519
520 let mut spans = vec![
521 Span::styled(format!("{} ", icon), Style::default().fg(color)),
522 Span::styled(
523 format!("[{}] ", story.id),
524 Style::default().fg(Color::DarkGray),
525 ),
526 Span::styled(&story.title, Style::default().fg(Color::White)),
527 ];
528
529 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}