1use crate::swarm::{SubTaskStatus, SwarmStats};
8use ratatui::{
9 Frame,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Color, Modifier, Style},
12 text::{Line, Span},
13 widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Wrap},
14};
15
16#[derive(Debug, Clone)]
18pub struct AgentToolCallDetail {
19 pub tool_name: String,
20 pub input_preview: String,
21 pub output_preview: String,
22 pub success: bool,
23}
24
25#[derive(Debug, Clone)]
27pub struct AgentMessageEntry {
28 pub role: String,
29 pub content: String,
30 pub is_tool_call: bool,
31}
32
33#[derive(Debug, Clone)]
35pub enum SwarmEvent {
36 Started { task: String, total_subtasks: usize },
38 Decomposed { subtasks: Vec<SubTaskInfo> },
40 SubTaskUpdate {
42 id: String,
43 name: String,
44 status: SubTaskStatus,
45 agent_name: Option<String>,
46 },
47 AgentStarted {
49 subtask_id: String,
50 agent_name: String,
51 specialty: String,
52 },
53 AgentToolCall {
55 subtask_id: String,
56 tool_name: String,
57 },
58 AgentToolCallDetail {
60 subtask_id: String,
61 detail: AgentToolCallDetail,
62 },
63 AgentMessage {
65 subtask_id: String,
66 entry: AgentMessageEntry,
67 },
68 AgentComplete {
70 subtask_id: String,
71 success: bool,
72 steps: usize,
73 },
74 AgentOutput { subtask_id: String, output: String },
76 AgentError { subtask_id: String, error: String },
78 StageComplete {
80 stage: usize,
81 completed: usize,
82 failed: usize,
83 },
84 Complete { success: bool, stats: SwarmStats },
86 Error(String),
88}
89
90#[derive(Debug, Clone)]
92pub struct SubTaskInfo {
93 pub id: String,
94 pub name: String,
95 pub status: SubTaskStatus,
96 pub stage: usize,
97 pub dependencies: Vec<String>,
98 pub agent_name: Option<String>,
99 pub current_tool: Option<String>,
100 pub steps: usize,
101 pub max_steps: usize,
102 pub tool_call_history: Vec<AgentToolCallDetail>,
104 pub messages: Vec<AgentMessageEntry>,
106 pub output: Option<String>,
108 pub error: Option<String>,
110}
111
112#[derive(Debug)]
114pub struct SwarmViewState {
115 pub active: bool,
117 pub task: String,
119 pub subtasks: Vec<SubTaskInfo>,
121 pub current_stage: usize,
123 pub total_stages: usize,
125 pub stats: Option<SwarmStats>,
127 pub error: Option<String>,
129 pub complete: bool,
131 pub selected_index: usize,
133 pub detail_mode: bool,
135 pub detail_scroll: usize,
137 pub list_state: ListState,
139}
140
141impl Default for SwarmViewState {
142 fn default() -> Self {
143 Self {
144 active: false,
145 task: String::new(),
146 subtasks: Vec::new(),
147 current_stage: 0,
148 total_stages: 0,
149 stats: None,
150 error: None,
151 complete: false,
152 selected_index: 0,
153 detail_mode: false,
154 detail_scroll: 0,
155 list_state: ListState::default(),
156 }
157 }
158}
159
160impl SwarmViewState {
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 pub fn handle_event(&mut self, event: SwarmEvent) {
167 match event {
168 SwarmEvent::Started {
169 task,
170 total_subtasks,
171 } => {
172 self.active = true;
173 self.task = task;
174 self.subtasks.clear();
175 self.current_stage = 0;
176 self.complete = false;
177 self.error = None;
178 self.selected_index = 0;
179 self.detail_mode = false;
180 self.detail_scroll = 0;
181 self.list_state = ListState::default();
182 self.subtasks.reserve(total_subtasks);
184 }
185 SwarmEvent::Decomposed { subtasks } => {
186 self.subtasks = subtasks;
187 self.total_stages = self.subtasks.iter().map(|s| s.stage).max().unwrap_or(0) + 1;
188 if !self.subtasks.is_empty() {
189 self.list_state.select(Some(0));
190 }
191 }
192 SwarmEvent::SubTaskUpdate {
193 id,
194 name,
195 status,
196 agent_name,
197 } => {
198 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == id) {
199 task.status = status;
200 task.name = name;
201 if agent_name.is_some() {
202 task.agent_name = agent_name;
203 }
204 }
205 }
206 SwarmEvent::AgentStarted {
207 subtask_id,
208 agent_name,
209 ..
210 } => {
211 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
212 task.status = SubTaskStatus::Running;
213 task.agent_name = Some(agent_name);
214 }
215 }
216 SwarmEvent::AgentToolCall {
217 subtask_id,
218 tool_name,
219 } => {
220 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
221 task.current_tool = Some(tool_name);
222 task.steps += 1;
223 }
224 }
225 SwarmEvent::AgentToolCallDetail { subtask_id, detail } => {
226 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
227 task.current_tool = Some(detail.tool_name.clone());
228 task.steps += 1;
229 task.tool_call_history.push(detail);
230 }
231 }
232 SwarmEvent::AgentMessage { subtask_id, entry } => {
233 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
234 task.messages.push(entry);
235 }
236 }
237 SwarmEvent::AgentComplete {
238 subtask_id,
239 success,
240 steps,
241 } => {
242 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
243 task.status = if success {
244 SubTaskStatus::Completed
245 } else {
246 SubTaskStatus::Failed
247 };
248 task.steps = steps;
249 task.current_tool = None;
250 }
251 }
252 SwarmEvent::AgentOutput { subtask_id, output } => {
253 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
254 task.output = Some(output);
255 }
256 }
257 SwarmEvent::AgentError { subtask_id, error } => {
258 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
259 task.error = Some(error);
260 }
261 }
262 SwarmEvent::StageComplete { stage, .. } => {
263 self.current_stage = stage + 1;
264 }
265 SwarmEvent::Complete { success: _, stats } => {
266 self.stats = Some(stats);
267 self.complete = true;
268 }
269 SwarmEvent::Error(err) => {
270 self.error = Some(err);
271 }
272 }
273 }
274
275 pub fn select_prev(&mut self) {
277 if self.subtasks.is_empty() {
278 return;
279 }
280 self.selected_index = self.selected_index.saturating_sub(1);
281 self.list_state.select(Some(self.selected_index));
282 }
283
284 pub fn select_next(&mut self) {
286 if self.subtasks.is_empty() {
287 return;
288 }
289 self.selected_index = (self.selected_index + 1).min(self.subtasks.len() - 1);
290 self.list_state.select(Some(self.selected_index));
291 }
292
293 pub fn enter_detail(&mut self) {
295 if !self.subtasks.is_empty() {
296 self.detail_mode = true;
297 self.detail_scroll = 0;
298 }
299 }
300
301 pub fn exit_detail(&mut self) {
303 self.detail_mode = false;
304 self.detail_scroll = 0;
305 }
306
307 pub fn detail_scroll_down(&mut self, amount: usize) {
309 self.detail_scroll = self.detail_scroll.saturating_add(amount);
310 }
311
312 pub fn detail_scroll_up(&mut self, amount: usize) {
314 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
315 }
316
317 pub fn selected_subtask(&self) -> Option<&SubTaskInfo> {
319 self.subtasks.get(self.selected_index)
320 }
321
322 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
324 let mut pending = 0;
325 let mut running = 0;
326 let mut completed = 0;
327 let mut failed = 0;
328
329 for task in &self.subtasks {
330 match task.status {
331 SubTaskStatus::Pending | SubTaskStatus::Blocked => pending += 1,
332 SubTaskStatus::Running => running += 1,
333 SubTaskStatus::Completed => completed += 1,
334 SubTaskStatus::Failed | SubTaskStatus::Cancelled | SubTaskStatus::TimedOut => {
335 failed += 1
336 }
337 }
338 }
339
340 (pending, running, completed, failed)
341 }
342
343 pub fn progress(&self) -> f64 {
345 if self.subtasks.is_empty() {
346 return 0.0;
347 }
348 let (_, _, completed, failed) = self.status_counts();
349 ((completed + failed) as f64 / self.subtasks.len() as f64) * 100.0
350 }
351}
352
353pub fn render_swarm_view(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
355 if state.detail_mode {
357 render_agent_detail(f, state, area);
358 return;
359 }
360
361 let chunks = Layout::default()
362 .direction(Direction::Vertical)
363 .constraints([
364 Constraint::Length(3), Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ])
369 .split(area);
370
371 render_header(f, state, chunks[0]);
373
374 render_stage_progress(f, state, chunks[1]);
376
377 render_subtask_list(f, state, chunks[2]);
379
380 render_stats(f, state, chunks[3]);
382}
383
384fn render_header(f: &mut Frame, state: &SwarmViewState, area: Rect) {
385 let (pending, running, completed, failed) = state.status_counts();
386 let total = state.subtasks.len();
387
388 let title = if state.complete {
389 if state.error.is_some() {
390 " Swarm [ERROR] "
391 } else {
392 " Swarm [COMPLETE] "
393 }
394 } else {
395 " Swarm [ACTIVE] "
396 };
397
398 let status_line = Line::from(vec![
399 Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
400 Span::styled(
401 if state.task.len() > 50 {
402 format!("{}...", &state.task[..47])
403 } else {
404 state.task.clone()
405 },
406 Style::default().fg(Color::White),
407 ),
408 Span::raw(" "),
409 Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
410 Span::raw(" "),
411 Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
412 Span::raw(" "),
413 Span::styled(format!("✓{}", completed), Style::default().fg(Color::Green)),
414 Span::raw(" "),
415 Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
416 Span::raw(" "),
417 Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
418 ]);
419
420 let paragraph = Paragraph::new(status_line).block(
421 Block::default()
422 .borders(Borders::ALL)
423 .title(title)
424 .border_style(Style::default().fg(if state.complete {
425 if state.error.is_some() {
426 Color::Red
427 } else {
428 Color::Green
429 }
430 } else {
431 Color::Cyan
432 })),
433 );
434
435 f.render_widget(paragraph, area);
436}
437
438fn render_stage_progress(f: &mut Frame, state: &SwarmViewState, area: Rect) {
439 let progress = state.progress();
440 let label = format!(
441 "Stage {}/{} - {:.0}%",
442 state.current_stage.min(state.total_stages),
443 state.total_stages,
444 progress
445 );
446
447 let gauge = Gauge::default()
448 .block(Block::default().borders(Borders::ALL).title(" Progress "))
449 .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
450 .percent(progress as u16)
451 .label(label);
452
453 f.render_widget(gauge, area);
454}
455
456fn render_subtask_list(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
457 state.list_state.select(Some(state.selected_index));
459
460 let items: Vec<ListItem> = state
461 .subtasks
462 .iter()
463 .map(|task| {
464 let (icon, color) = match task.status {
465 SubTaskStatus::Pending => ("○", Color::DarkGray),
466 SubTaskStatus::Blocked => ("⊘", Color::Yellow),
467 SubTaskStatus::Running => ("●", Color::Cyan),
468 SubTaskStatus::Completed => ("✓", Color::Green),
469 SubTaskStatus::Failed => ("✗", Color::Red),
470 SubTaskStatus::Cancelled => ("⊗", Color::DarkGray),
471 SubTaskStatus::TimedOut => ("⏱", Color::Red),
472 };
473
474 let mut spans = vec![
475 Span::styled(format!("{} ", icon), Style::default().fg(color)),
476 Span::styled(
477 format!("[S{}] ", task.stage),
478 Style::default().fg(Color::DarkGray),
479 ),
480 Span::styled(&task.name, Style::default().fg(Color::White)),
481 ];
482
483 if task.status == SubTaskStatus::Running {
485 if let Some(ref agent) = task.agent_name {
486 spans.push(Span::styled(
487 format!(" → {}", agent),
488 Style::default().fg(Color::Cyan),
489 ));
490 }
491 if let Some(ref tool) = task.current_tool {
492 spans.push(Span::styled(
493 format!(" [{}]", tool),
494 Style::default()
495 .fg(Color::Yellow)
496 .add_modifier(Modifier::DIM),
497 ));
498 }
499 }
500
501 if task.steps > 0 {
503 spans.push(Span::styled(
504 format!(" ({}/{})", task.steps, task.max_steps),
505 Style::default().fg(Color::DarkGray),
506 ));
507 }
508
509 ListItem::new(Line::from(spans))
510 })
511 .collect();
512
513 let title = if state.subtasks.is_empty() {
514 " SubTasks (none yet) "
515 } else {
516 " SubTasks (↑↓:select Enter:detail) "
517 };
518
519 let list = List::new(items)
520 .block(Block::default().borders(Borders::ALL).title(title))
521 .highlight_style(
522 Style::default()
523 .add_modifier(Modifier::BOLD)
524 .bg(Color::DarkGray),
525 )
526 .highlight_symbol("▶ ");
527
528 f.render_stateful_widget(list, area, &mut state.list_state);
529}
530
531fn render_agent_detail(f: &mut Frame, state: &SwarmViewState, area: Rect) {
533 let task = match state.selected_subtask() {
534 Some(t) => t,
535 None => {
536 let p = Paragraph::new("No subtask selected").block(
537 Block::default()
538 .borders(Borders::ALL)
539 .title(" Agent Detail "),
540 );
541 f.render_widget(p, area);
542 return;
543 }
544 };
545
546 let chunks = Layout::default()
547 .direction(Direction::Vertical)
548 .constraints([
549 Constraint::Length(5), Constraint::Min(1), Constraint::Length(1), ])
553 .split(area);
554
555 let (status_icon, status_color) = match task.status {
557 SubTaskStatus::Pending => ("○ Pending", Color::DarkGray),
558 SubTaskStatus::Blocked => ("⊘ Blocked", Color::Yellow),
559 SubTaskStatus::Running => ("● Running", Color::Cyan),
560 SubTaskStatus::Completed => ("✓ Completed", Color::Green),
561 SubTaskStatus::Failed => ("✗ Failed", Color::Red),
562 SubTaskStatus::Cancelled => ("⊗ Cancelled", Color::DarkGray),
563 SubTaskStatus::TimedOut => ("⏱ Timed Out", Color::Red),
564 };
565
566 let agent_label = task.agent_name.as_deref().unwrap_or("(unassigned)");
567 let header_lines = vec![
568 Line::from(vec![
569 Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
570 Span::styled(
571 &task.name,
572 Style::default()
573 .fg(Color::White)
574 .add_modifier(Modifier::BOLD),
575 ),
576 ]),
577 Line::from(vec![
578 Span::styled("Agent: ", Style::default().fg(Color::DarkGray)),
579 Span::styled(agent_label, Style::default().fg(Color::Cyan)),
580 Span::raw(" "),
581 Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
582 Span::styled(status_icon, Style::default().fg(status_color)),
583 Span::raw(" "),
584 Span::styled("Stage: ", Style::default().fg(Color::DarkGray)),
585 Span::styled(format!("{}", task.stage), Style::default().fg(Color::White)),
586 Span::raw(" "),
587 Span::styled("Steps: ", Style::default().fg(Color::DarkGray)),
588 Span::styled(
589 format!("{}/{}", task.steps, task.max_steps),
590 Style::default().fg(Color::White),
591 ),
592 ]),
593 Line::from(vec![
594 Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
595 Span::styled(
596 if task.dependencies.is_empty() {
597 "none".to_string()
598 } else {
599 task.dependencies.join(", ")
600 },
601 Style::default().fg(Color::DarkGray),
602 ),
603 ]),
604 ];
605
606 let title = format!(" Agent Detail: {} ", task.id);
607 let header = Paragraph::new(header_lines).block(
608 Block::default()
609 .borders(Borders::ALL)
610 .title(title)
611 .border_style(Style::default().fg(status_color)),
612 );
613 f.render_widget(header, chunks[0]);
614
615 let mut content_lines: Vec<Line> = Vec::new();
617
618 if !task.tool_call_history.is_empty() {
620 content_lines.push(Line::from(Span::styled(
621 "─── Tool Call History ───",
622 Style::default()
623 .fg(Color::Cyan)
624 .add_modifier(Modifier::BOLD),
625 )));
626 content_lines.push(Line::from(""));
627
628 for (i, tc) in task.tool_call_history.iter().enumerate() {
629 let icon = if tc.success { "✓" } else { "✗" };
630 let icon_color = if tc.success { Color::Green } else { Color::Red };
631 content_lines.push(Line::from(vec![
632 Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
633 Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::DarkGray)),
634 Span::styled(
635 &tc.tool_name,
636 Style::default()
637 .fg(Color::Yellow)
638 .add_modifier(Modifier::BOLD),
639 ),
640 ]));
641 if !tc.input_preview.is_empty() {
642 content_lines.push(Line::from(vec![
643 Span::raw(" "),
644 Span::styled("in: ", Style::default().fg(Color::DarkGray)),
645 Span::styled(
646 truncate_str(&tc.input_preview, 80),
647 Style::default().fg(Color::White),
648 ),
649 ]));
650 }
651 if !tc.output_preview.is_empty() {
652 content_lines.push(Line::from(vec![
653 Span::raw(" "),
654 Span::styled("out: ", Style::default().fg(Color::DarkGray)),
655 Span::styled(
656 truncate_str(&tc.output_preview, 80),
657 Style::default().fg(Color::White),
658 ),
659 ]));
660 }
661 }
662 content_lines.push(Line::from(""));
663 } else if task.steps > 0 {
664 content_lines.push(Line::from(Span::styled(
666 format!("─── {} tool calls (no detail captured) ───", task.steps),
667 Style::default().fg(Color::DarkGray),
668 )));
669 content_lines.push(Line::from(""));
670 }
671
672 if !task.messages.is_empty() {
674 content_lines.push(Line::from(Span::styled(
675 "─── Conversation ───",
676 Style::default()
677 .fg(Color::Cyan)
678 .add_modifier(Modifier::BOLD),
679 )));
680 content_lines.push(Line::from(""));
681
682 for msg in &task.messages {
683 let (role_color, role_label) = match msg.role.as_str() {
684 "user" => (Color::White, "USER"),
685 "assistant" => (Color::Cyan, "ASST"),
686 "tool" => (Color::Yellow, "TOOL"),
687 "system" => (Color::DarkGray, "SYS "),
688 _ => (Color::White, " "),
689 };
690 content_lines.push(Line::from(vec![Span::styled(
691 format!(" [{role_label}] "),
692 Style::default().fg(role_color).add_modifier(Modifier::BOLD),
693 )]));
694 for line in msg.content.lines().take(10) {
696 content_lines.push(Line::from(vec![
697 Span::raw(" "),
698 Span::styled(line, Style::default().fg(Color::White)),
699 ]));
700 }
701 if msg.content.lines().count() > 10 {
702 content_lines.push(Line::from(vec![
703 Span::raw(" "),
704 Span::styled("... (truncated)", Style::default().fg(Color::DarkGray)),
705 ]));
706 }
707 content_lines.push(Line::from(""));
708 }
709 }
710
711 if let Some(ref output) = task.output {
713 content_lines.push(Line::from(Span::styled(
714 "─── Output ───",
715 Style::default()
716 .fg(Color::Green)
717 .add_modifier(Modifier::BOLD),
718 )));
719 content_lines.push(Line::from(""));
720 for line in output.lines().take(20) {
721 content_lines.push(Line::from(Span::styled(
722 line,
723 Style::default().fg(Color::White),
724 )));
725 }
726 if output.lines().count() > 20 {
727 content_lines.push(Line::from(Span::styled(
728 "... (truncated)",
729 Style::default().fg(Color::DarkGray),
730 )));
731 }
732 content_lines.push(Line::from(""));
733 }
734
735 if let Some(ref err) = task.error {
737 content_lines.push(Line::from(Span::styled(
738 "─── Error ───",
739 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
740 )));
741 content_lines.push(Line::from(""));
742 for line in err.lines() {
743 content_lines.push(Line::from(Span::styled(
744 line,
745 Style::default().fg(Color::Red),
746 )));
747 }
748 content_lines.push(Line::from(""));
749 }
750
751 if content_lines.is_empty() {
753 content_lines.push(Line::from(Span::styled(
754 " Waiting for agent activity...",
755 Style::default().fg(Color::DarkGray),
756 )));
757 }
758
759 let content = Paragraph::new(content_lines)
760 .block(Block::default().borders(Borders::ALL))
761 .wrap(Wrap { trim: false })
762 .scroll((state.detail_scroll as u16, 0));
763 f.render_widget(content, chunks[1]);
764
765 let hints = Paragraph::new(Line::from(vec![
767 Span::styled(" Esc", Style::default().fg(Color::Yellow)),
768 Span::raw(": Back "),
769 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
770 Span::raw(": Scroll "),
771 Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
772 Span::raw(": Prev/Next agent"),
773 ]));
774 f.render_widget(hints, chunks[2]);
775}
776
777fn truncate_str(s: &str, max: usize) -> String {
779 if s.len() <= max {
780 s.replace('\n', " ")
781 } else {
782 let mut end = max;
783 while end > 0 && !s.is_char_boundary(end) {
784 end -= 1;
785 }
786 format!("{}...", s[..end].replace('\n', " "))
787 }
788}
789
790fn render_stats(f: &mut Frame, state: &SwarmViewState, area: Rect) {
791 let content = if let Some(ref stats) = state.stats {
792 Line::from(vec![
793 Span::styled("Time: ", Style::default().fg(Color::DarkGray)),
794 Span::styled(
795 format!("{:.1}s", stats.execution_time_ms as f64 / 1000.0),
796 Style::default().fg(Color::White),
797 ),
798 Span::raw(" "),
799 Span::styled("Speedup: ", Style::default().fg(Color::DarkGray)),
800 Span::styled(
801 format!("{:.1}x", stats.speedup_factor),
802 Style::default().fg(Color::Green),
803 ),
804 Span::raw(" "),
805 Span::styled("Tool calls: ", Style::default().fg(Color::DarkGray)),
806 Span::styled(
807 format!("{}", stats.total_tool_calls),
808 Style::default().fg(Color::White),
809 ),
810 Span::raw(" "),
811 Span::styled("Critical path: ", Style::default().fg(Color::DarkGray)),
812 Span::styled(
813 format!("{}", stats.critical_path_length),
814 Style::default().fg(Color::White),
815 ),
816 ])
817 } else if let Some(ref err) = state.error {
818 Line::from(vec![Span::styled(
819 format!("Error: {}", err),
820 Style::default().fg(Color::Red),
821 )])
822 } else {
823 Line::from(vec![Span::styled(
824 "Executing...",
825 Style::default().fg(Color::DarkGray),
826 )])
827 };
828
829 let paragraph = Paragraph::new(content)
830 .block(Block::default().borders(Borders::ALL).title(" Stats "))
831 .wrap(Wrap { trim: true });
832
833 f.render_widget(paragraph, area);
834}