1use std::collections::BTreeMap;
2
3use diffy::Line as DiffLine;
4use diffy::Patch;
5use ratatui::Frame;
6use ratatui::layout::Alignment;
7use ratatui::layout::Constraint;
8use ratatui::layout::Layout;
9use ratatui::layout::Rect;
10use ratatui::style::Modifier;
11use ratatui::style::Style;
12use ratatui::text::Line;
13use ratatui::text::Span;
14use ratatui::text::Text;
15use ratatui::widgets::Block;
16use ratatui::widgets::Paragraph;
17use ratatui::widgets::Wrap;
18
19use super::PreviewContent;
20use super::TranscriptEntry;
21use super::TranscriptTone;
22use super::TuiState;
23use super::highlight;
24use super::markdown;
25use super::shimmer;
26use crate::frontend::catalog::MenuItem;
27use crate::frontend::catalog::UiCatalog;
28use crate::frontend::theme::Role;
29use crate::frontend::theme::current;
30use horus::protocol::FrontendBlockFormat;
31use horus::protocol::FrontendSlot;
32use horus::protocol::FrontendTone;
33use horus::protocol::FrontendWidget;
34
35const MAX_MENU_ROWS: usize = 6;
36const COMPOSER_PROMPT: &str = "» ";
37const AGENT_MARKER: &str = "◉ ";
38const WELCOME_EYE: [&str; 6] = [
39 " ⣠⡤⢶⣛⣯⣭⣭⣟⣳⠶⣤⣀ ",
40 "⣴⣾⡽⠞⠋⣽⠉ ⠈⢻⠉⠙⠷⣭⣳⠦",
41 "⠛⠙⠛⠓⠶⠾⠷⣤⣴⠿⠶⠚⠛⠉⠉⠛",
42 " ⢠⡶⢻⡟⢷⣄ ",
43 " ⢻⣼⡇ ⠹⣦⡀⣀⣀⡀",
44 " ⠈⣿⡇ ⠈⠻⣟⣀⡿",
45];
46
47pub(super) fn render(frame: &mut Frame<'_>, state: &mut TuiState, catalog: &UiCatalog) {
48 let theme = current();
49 frame.render_widget(
50 Block::default().style(theme.style(Role::Canvas)),
51 frame.area(),
52 );
53 let reference_suggestions = state
54 .picker
55 .is_none()
56 .then(|| {
57 state
58 .reference_suggestions(catalog)
59 .map(|(_, matches)| matches)
60 })
61 .flatten();
62 let slash_suggestions = (state.picker.is_none() && reference_suggestions.is_none())
63 .then(|| catalog.command_suggestions(&state.input, state.cursor))
64 .flatten();
65 let menu_height = if let Some(picker) = &state.picker {
66 u16::try_from(picker.options.len().clamp(1, MAX_MENU_ROWS) + 1).unwrap_or(0)
67 } else {
68 reference_suggestions
69 .as_ref()
70 .map(Vec::len)
71 .or_else(|| slash_suggestions.as_ref().map(Vec::len))
72 .map_or(0, |length| {
73 u16::try_from(length.clamp(1, MAX_MENU_ROWS)).unwrap_or(0)
74 })
75 };
76 let (input, cursor_end) = marked_input(state);
77 let inner_width = frame.area().width.saturating_sub(2).max(1);
78 let input_rows = Paragraph::new(input.as_str())
79 .wrap(Wrap { trim: false })
80 .line_count(inner_width);
81 let max_composer_height = frame
82 .area()
83 .height
84 .saturating_sub(menu_height.saturating_add(3))
85 .max(3);
86 let composer_height = u16::try_from(input_rows)
87 .unwrap_or(u16::MAX)
88 .saturating_add(2)
89 .clamp(3, max_composer_height);
90 let input_row = input_cursor_row(&input[..cursor_end], inner_width);
91 let areas = Layout::vertical([
92 Constraint::Min(1),
93 Constraint::Length(menu_height),
94 Constraint::Length(1),
95 Constraint::Length(composer_height),
96 Constraint::Length(1),
97 ])
98 .split(frame.area());
99
100 render_transcript(frame, state, areas[0]);
101 if let Some(picker) = state.picker.as_mut() {
102 picker.selected = picker.selected.min(picker.options.len().saturating_sub(1));
103 render_picker_menu(frame, areas[1], picker);
104 } else if let Some(suggestions) = reference_suggestions {
105 state.reference_selection = state
106 .reference_selection
107 .min(suggestions.len().saturating_sub(1));
108 render_menu(frame, areas[1], &suggestions, state.reference_selection);
109 } else if let Some(suggestions) = slash_suggestions {
110 state.slash_selection = state
111 .slash_selection
112 .min(suggestions.len().saturating_sub(1));
113 render_menu(frame, areas[1], &suggestions, state.slash_selection);
114 }
115 frame.render_widget(Paragraph::new(composer_header_line(state)), areas[2]);
116 frame.render_widget(
117 Paragraph::new(input)
118 .style(theme.style(Role::Text))
119 .block(
120 Block::bordered()
121 .border_style(theme.style(Role::Border))
122 .title(composer_title(state)),
123 )
124 .scroll((
125 input_row.saturating_sub(composer_height.saturating_sub(3)),
126 0,
127 ))
128 .wrap(Wrap { trim: false }),
129 areas[3],
130 );
131 render_footer(frame, state, areas[4]);
132}
133
134fn render_transcript(frame: &mut Frame<'_>, state: &mut TuiState, area: Rect) {
135 let lines = live_transcript_lines(state, 0, area.width);
136 let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
137 let rendered_lines = paragraph.line_count(area.width);
138 state
139 .transcript_viewport
140 .update(rendered_lines, usize::from(area.height));
141 let scroll = state
142 .transcript_viewport
143 .effective_scroll()
144 .min(usize::from(u16::MAX)) as u16;
145 frame.render_widget(paragraph.scroll((scroll, 0)), area);
146}
147
148pub(super) fn live_transcript_lines(
149 state: &mut TuiState,
150 start: usize,
151 width: u16,
152) -> Vec<Line<'static>> {
153 let previous_group = start
154 .checked_sub(1)
155 .and_then(|index| state.transcript.get(index))
156 .and_then(|entry| entry.group.clone());
157 let mut lines = transcript_lines(
158 state.transcript.iter_mut().skip(start),
159 width,
160 previous_group,
161 start > 0,
162 );
163 if !state.streaming.is_empty() {
164 push_lines(
165 &mut lines,
166 &state.streaming,
167 TranscriptTone::Assistant,
168 FrontendBlockFormat::PlainText,
169 width,
170 );
171 }
172 if !state.reasoning.is_empty() {
173 push_lines(
174 &mut lines,
175 &state.reasoning,
176 TranscriptTone::Reasoning,
177 FrontendBlockFormat::PlainText,
178 width,
179 );
180 }
181 if lines.is_empty() {
182 let card = responsive_welcome_card(state, width);
183 push_lines(
184 &mut lines,
185 &card,
186 TranscriptTone::Welcome,
187 FrontendBlockFormat::PlainText,
188 width,
189 );
190 }
191 lines
192}
193
194pub(super) fn render_preview(frame: &mut Frame<'_>, state: &mut TuiState) {
195 let theme = current();
196 let area = frame.area();
197 if area.width < 3 || area.height < 3 {
198 return;
199 }
200 let (title, live) = {
201 let Some(preview) = state.preview.as_ref() else {
202 return;
203 };
204 (
205 preview.title.clone(),
206 matches!(&preview.content, PreviewContent::LiveTranscript),
207 )
208 };
209 let block = Block::bordered()
210 .style(theme.style(Role::Canvas))
211 .border_style(theme.style(Role::Info))
212 .title(Line::styled(
213 format!(" {title} · ↑↓/PgUp/PgDn scroll · drag to copy · Esc/Ctrl+T close "),
214 theme.style(Role::Accent).add_modifier(Modifier::BOLD),
215 ));
216 let inner = block.inner(area);
217 let mut lines = if live {
218 live_transcript_lines(state, 0, inner.width)
219 } else if let Some(PreviewContent::Snapshot(transcript)) =
220 state.preview.as_mut().map(|preview| &mut preview.content)
221 {
222 transcript_lines(transcript.iter_mut(), inner.width, None, false)
223 } else {
224 Vec::new()
225 };
226 if lines.is_empty() {
227 lines.push(Line::styled(
228 "No transcript events.",
229 theme.style(Role::Muted).add_modifier(Modifier::ITALIC),
230 ));
231 }
232 let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
233 let rendered_lines = paragraph.line_count(inner.width);
234 let preview = state.preview.as_mut().expect("preview checked");
235 preview
236 .viewport
237 .update(rendered_lines, usize::from(inner.height));
238 let scroll = preview
239 .viewport
240 .effective_scroll()
241 .min(usize::from(u16::MAX)) as u16;
242
243 frame.render_widget(paragraph.block(block).scroll((scroll, 0)), area);
244}
245
246fn transcript_lines<'a>(
247 entries: impl Iterator<Item = &'a mut TranscriptEntry>,
248 width: u16,
249 mut previous_group: Option<String>,
250 mut has_previous: bool,
251) -> Vec<Line<'static>> {
252 let mut lines = Vec::new();
253 for entry in entries {
254 let grouped = entry.group.is_some() && entry.group == previous_group;
255 if has_previous && !grouped {
256 if matches!(entry.tone, TranscriptTone::User) {
257 lines.push(Line::styled(
258 "─".repeat(usize::from(width)),
259 current().style(Role::Border),
260 ));
261 } else {
262 lines.push(Line::default());
263 }
264 }
265 if entry
266 .rendered
267 .as_ref()
268 .is_none_or(|(cached_width, _)| *cached_width != width)
269 {
270 let text = if matches!(entry.tone, TranscriptTone::Welcome)
271 && entry
272 .text
273 .lines()
274 .any(|line| Line::from(line).width() > usize::from(width))
275 {
276 "◉ HORUS · type / for commands"
277 } else {
278 &entry.text
279 };
280 let mut rendered = Vec::new();
281 push_lines(&mut rendered, text, entry.tone, entry.format, width);
282 entry.rendered = Some((width, rendered));
283 }
284 if let Some((_, rendered)) = &entry.rendered {
285 lines.extend(rendered.iter().cloned());
286 }
287 previous_group.clone_from(&entry.group);
288 has_previous = true;
289 }
290 lines
291}
292
293fn push_lines(
294 lines: &mut Vec<Line<'static>>,
295 text: &str,
296 tone: TranscriptTone,
297 format: FrontendBlockFormat,
298 width: u16,
299) {
300 if format == FrontendBlockFormat::UnifiedDiff
301 && push_unified_diff(lines, text, usize::from(width))
302 {
303 return;
304 }
305 let theme = current();
306 let mut style = theme.style(transcript_role(tone));
307 if matches!(tone, TranscriptTone::Reasoning) {
308 style = style.add_modifier(Modifier::ITALIC);
309 } else if matches!(tone, TranscriptTone::Welcome) {
310 style = style.add_modifier(Modifier::BOLD);
311 }
312 let first = lines.len();
313 if matches!(tone, TranscriptTone::Assistant | TranscriptTone::Reasoning) {
314 lines.extend(markdown::render(text, style));
315 } else {
316 lines.extend(text.split('\n').map(|line| {
317 line.strip_prefix(AGENT_MARKER).map_or_else(
318 || Line::styled(line.to_string(), style),
319 |content| {
320 Line::from(vec![
321 Span::styled(AGENT_MARKER, theme.style(Role::Accent)),
322 Span::styled(content.to_string(), style),
323 ])
324 },
325 )
326 }));
327 }
328 if matches!(tone, TranscriptTone::Assistant | TranscriptTone::Reasoning) {
329 for (index, line) in lines[first..].iter_mut().enumerate() {
330 line.spans.insert(
331 0,
332 Span::styled(
333 if index == 0 { AGENT_MARKER } else { " " },
334 theme.style(Role::Accent),
335 ),
336 );
337 }
338 }
339}
340
341fn push_unified_diff(lines: &mut Vec<Line<'static>>, text: &str, width: usize) -> bool {
342 let Ok(patch) = Patch::from_str(text) else {
343 return false;
344 };
345 let theme = current();
346 let raw_path = patch
347 .modified()
348 .or_else(|| patch.original())
349 .unwrap_or("file");
350 let path = terminal_text(raw_path);
351 let (added, removed, max_line_number) =
352 patch
353 .hunks()
354 .iter()
355 .fold((0, 0, 0), |(added, removed, max), hunk| {
356 let mut old_line = hunk.old_range().start();
357 let mut new_line = hunk.new_range().start();
358 let mut added = added;
359 let mut removed = removed;
360 let mut max = max;
361 for line in hunk.lines() {
362 match line {
363 DiffLine::Insert(_) => {
364 added += 1;
365 max = max.max(new_line);
366 new_line += 1;
367 }
368 DiffLine::Delete(_) => {
369 removed += 1;
370 max = max.max(old_line);
371 old_line += 1;
372 }
373 DiffLine::Context(_) => {
374 max = max.max(new_line);
375 old_line += 1;
376 new_line += 1;
377 }
378 }
379 }
380 (added, removed, max)
381 });
382 lines.push(Line::from(vec![
383 Span::styled(AGENT_MARKER, theme.style(Role::Accent)),
384 Span::styled(
385 "Edited ",
386 theme.style(Role::Text).add_modifier(Modifier::BOLD),
387 ),
388 Span::styled(path, theme.style(Role::Code)),
389 Span::raw(" ("),
390 Span::styled(format!("+{added}"), theme.style(Role::Success)),
391 Span::raw(" "),
392 Span::styled(format!("-{removed}"), theme.style(Role::Error)),
393 Span::raw(")"),
394 ]));
395
396 let number_width = max_line_number.max(1).to_string().len();
397 for (hunk_index, hunk) in patch.hunks().iter().enumerate() {
398 if hunk_index > 0 {
399 lines.push(Line::styled(
400 format!(" {:>number_width$} ⋮", ""),
401 theme.style(Role::Muted),
402 ));
403 }
404 let mut old_line = hunk.old_range().start();
405 let mut new_line = hunk.new_range().start();
406 let hunk_text = hunk
407 .lines()
408 .iter()
409 .map(|line| match line {
410 DiffLine::Insert(content)
411 | DiffLine::Delete(content)
412 | DiffLine::Context(content) => *content,
413 })
414 .collect::<String>();
415 let syntax = highlight::lines(&hunk_text, raw_path)
416 .filter(|syntax| syntax.len() == hunk.lines().len());
417 for (index, line) in hunk.lines().iter().enumerate() {
418 let (number, sign, sign_role, background, content) = match line {
419 DiffLine::Insert(content) => {
420 let number = new_line;
421 new_line += 1;
422 (
423 number,
424 "+",
425 Role::Success,
426 Some(theme.diff_add_background()),
427 content,
428 )
429 }
430 DiffLine::Delete(content) => {
431 let number = old_line;
432 old_line += 1;
433 (
434 number,
435 "-",
436 Role::Error,
437 Some(theme.diff_delete_background()),
438 content,
439 )
440 }
441 DiffLine::Context(content) => {
442 let number = new_line;
443 old_line += 1;
444 new_line += 1;
445 (number, " ", Role::Text, None, content)
446 }
447 };
448 let mut spans = vec![
449 Span::styled(
450 format!(" {number:>number_width$} "),
451 theme.style(Role::Muted),
452 ),
453 Span::styled(sign, theme.style(sign_role)),
454 ];
455 if let Some(syntax) = syntax
456 .as_ref()
457 .and_then(|syntax_lines| syntax_lines.get(index))
458 {
459 spans.extend(syntax.iter().cloned());
460 } else {
461 spans.push(Span::styled(
462 content.trim_end_matches(['\n', '\r']).to_string(),
463 theme.style(Role::Text),
464 ));
465 }
466 let mut line = Line::from(spans);
467 let padding = width.saturating_sub(line.width());
468 if padding > 0 {
469 line.push_span(Span::raw(" ".repeat(padding)));
470 }
471 if let Some(background) = background {
472 line = line.style(Style::default().bg(background));
473 }
474 lines.push(line);
475 }
476 }
477 true
478}
479
480fn transcript_role(tone: TranscriptTone) -> Role {
481 match tone {
482 TranscriptTone::Welcome => Role::Accent,
483 TranscriptTone::Assistant | TranscriptTone::User => Role::Text,
484 TranscriptTone::Reasoning => Role::Reasoning,
485 TranscriptTone::Neutral => Role::Neutral,
486 TranscriptTone::Success => Role::Success,
487 TranscriptTone::Warning => Role::Warning,
488 TranscriptTone::Error => Role::Error,
489 }
490}
491
492pub(super) fn welcome_card(state: &TuiState) -> String {
493 let details = state.agent_summary.lines().chain(std::iter::repeat(""));
494 let rows = WELCOME_EYE
495 .iter()
496 .zip(details)
497 .map(|(eye, detail)| {
498 if detail.is_empty() {
499 (*eye).to_owned()
500 } else {
501 format!("{eye} {detail}")
502 }
503 })
504 .collect::<Vec<_>>();
505 bordered_card(rows)
506}
507
508fn responsive_welcome_card(state: &TuiState, width: u16) -> String {
509 let welcome = welcome_card(state);
510 if card_fits(&welcome, width) {
511 return welcome;
512 }
513 let stacked = bordered_card(
514 WELCOME_EYE
515 .iter()
516 .map(|line| (*line).to_owned())
517 .chain(std::iter::once(String::new()))
518 .chain(state.agent_summary.lines().map(str::to_owned))
519 .collect(),
520 );
521 if card_fits(&stacked, width) {
522 return stacked;
523 }
524 let agent = bordered_card(state.agent_summary.lines().map(str::to_owned).collect());
525 if card_fits(&agent, width) {
526 agent
527 } else {
528 "◉ HORUS AGENT · type / for commands".into()
529 }
530}
531
532fn card_fits(card: &str, width: u16) -> bool {
533 card.lines()
534 .all(|line| Line::from(line).width() <= usize::from(width))
535}
536
537fn bordered_card(rows: Vec<String>) -> String {
538 let width = rows
539 .iter()
540 .map(|row| Line::from(row.as_str()).width())
541 .max()
542 .unwrap_or_default();
543 let border = "─".repeat(width + 2);
544 let mut lines = vec![format!("╭{border}╮")];
545 lines.extend(rows.into_iter().map(|row| {
546 let padding = width.saturating_sub(Line::from(row.as_str()).width());
547 format!("│ {row}{} │", " ".repeat(padding))
548 }));
549 lines.push(format!("╰{border}╯"));
550 lines.join("\n")
551}
552
553fn tone_role(tone: FrontendTone) -> Role {
554 match tone {
555 FrontendTone::Neutral => Role::Neutral,
556 FrontendTone::Success => Role::Success,
557 FrontendTone::Warning => Role::Warning,
558 FrontendTone::Error => Role::Error,
559 }
560}
561
562fn marked_input(state: &TuiState) -> (String, usize) {
563 let (mut input, cursor) = state.visible_input();
564 input.insert(cursor, '█');
565 (
566 format!("{COMPOSER_PROMPT}{input}"),
567 COMPOSER_PROMPT.len() + cursor + '█'.len_utf8(),
568 )
569}
570
571fn input_cursor_row(input_through_cursor: &str, width: u16) -> u16 {
572 let rows = Paragraph::new(input_through_cursor)
573 .wrap(Wrap { trim: false })
574 .line_count(width.max(1));
575 u16::try_from(rows.saturating_sub(1)).unwrap_or(u16::MAX)
576}
577
578fn render_footer(frame: &mut Frame<'_>, state: &TuiState, area: Rect) {
579 frame.render_widget(
580 Paragraph::new(footer_line(state, area.width)).alignment(Alignment::Right),
581 area,
582 );
583}
584
585fn footer_line(state: &TuiState, width: u16) -> Line<'static> {
586 let theme = current();
587 let reasoning = state.model.reasoning_effort.as_deref().unwrap_or("—");
588 let context = state
589 .usage
590 .context_remaining
591 .map_or_else(|| "—".into(), |value| format!("{value:.1}%"));
592 let cache = state
593 .usage
594 .cache_hit
595 .map_or_else(|| "—".into(), |value| format!("{value:.1}%"));
596 let folder = state
597 .cwd
598 .rsplit(['/', '\\'])
599 .find(|part| !part.is_empty())
600 .unwrap_or(&state.cwd);
601 let values = [
602 (format!("cache {cache}"), Role::Neutral),
603 (format!("context {context}"), Role::Code),
604 (
605 format!(
606 "{} {}",
607 display_value(&state.model.model),
608 display_value(reasoning)
609 ),
610 Role::Reasoning,
611 ),
612 (display_value(folder), Role::Info),
613 ];
614 let mut widget_spans = widget_line(&state.widgets, FrontendSlot::Header).spans;
615 let footer_widgets = widget_line(&state.widgets, FrontendSlot::ComposerFooter);
616 if !footer_widgets.spans.is_empty() {
617 separator(&mut widget_spans);
618 widget_spans.extend(footer_widgets.spans);
619 }
620 let mut spans = widget_spans.clone();
621 for (value, role) in &values {
622 separator(&mut spans);
623 spans.push(Span::styled(value.clone(), theme.style(*role)));
624 }
625 let full = Line::from(spans);
626 if full.width() <= usize::from(width) {
627 return full;
628 }
629
630 let mut spans = widget_spans;
631 for (value, role) in [&values[2], &values[1], &values[3]] {
632 let mut candidate = spans.clone();
633 separator(&mut candidate);
634 candidate.push(Span::styled(value.clone(), theme.style(*role)));
635 if Line::from(candidate.clone()).width() <= usize::from(width) {
636 spans = candidate;
637 }
638 }
639 if spans.is_empty() {
640 Line::styled(values[2].0.clone(), theme.style(values[2].1))
641 } else {
642 Line::from(spans)
643 }
644}
645
646fn widget_line(
647 widgets: &BTreeMap<(String, String), FrontendWidget>,
648 slot: FrontendSlot,
649) -> Line<'static> {
650 let theme = current();
651 let mut spans = Vec::new();
652 for item in widgets.values().filter(|item| item.slot == slot) {
653 separator(&mut spans);
654 let style = if slot == FrontendSlot::ComposerHeader && item.tone == FrontendTone::Neutral {
655 theme.style(Role::Muted).add_modifier(Modifier::ITALIC)
656 } else {
657 theme.style(tone_role(item.tone))
658 };
659 spans.push(Span::styled(item.text.clone(), style));
660 }
661 Line::from(spans)
662}
663
664fn composer_header_line(state: &TuiState) -> Line<'static> {
665 let mut line = status_line(state);
666 let widgets = widget_line(&state.widgets, FrontendSlot::ComposerHeader);
667 if line.width() > 0 && widgets.width() > 0 {
668 line.push_span(Span::styled(" · ", current().style(Role::Muted)));
669 }
670 for span in widgets.spans {
671 line.push_span(span);
672 }
673 line
674}
675
676fn separator(spans: &mut Vec<Span<'static>>) {
677 if !spans.is_empty() {
678 spans.push(Span::styled(" · ", current().style(Role::Muted)));
679 }
680}
681
682fn composer_title(state: &TuiState) -> Line<'static> {
683 let theme = current();
684 let (status, role) = if state.approval.is_some() {
685 ("approval".to_string(), Role::Warning)
686 } else if state.disconnected {
687 ("disconnected".to_string(), Role::Error)
688 } else if state.is_working() {
689 ("working".to_string(), Role::Accent)
690 } else {
691 ("ready".to_string(), Role::Accent)
692 };
693 let elapsed = state
694 .turn_started_at
695 .map(|started| format!(" · {}", elapsed_label(started.elapsed())))
696 .unwrap_or_default();
697 let title = format!(" horus · {status}{elapsed} ");
698 if state.is_working() {
699 shimmer::line(
700 &title,
701 theme.color(Role::Accent),
702 theme.color(Role::AccentStrong),
703 )
704 } else {
705 Line::styled(title, theme.style(role))
706 }
707}
708
709fn status_line(state: &TuiState) -> Line<'static> {
710 let theme = current();
711 if state.approval.is_some() {
712 return Line::styled(
713 "approval · y once · a session · n deny · q abort",
714 theme.style(Role::Warning),
715 );
716 }
717 if state.input_limit_reached {
718 return Line::styled(
719 "input limit reached · maximum 1 MiB",
720 theme.style(Role::Warning),
721 );
722 }
723 let status = latest_status(&state.status_message);
724 if state.is_working() && !status.is_empty() {
725 return shimmer::line(
726 &format!("{AGENT_MARKER}{}", display_value(status)),
727 theme.color(Role::Accent),
728 theme.color(Role::AccentStrong),
729 );
730 }
731 Line::default()
732}
733
734fn latest_status(status: &str) -> &str {
735 status
736 .lines()
737 .rev()
738 .find(|line| !line.trim().is_empty())
739 .unwrap_or_default()
740 .trim()
741}
742
743fn elapsed_label(elapsed: std::time::Duration) -> String {
744 let seconds = elapsed.as_secs();
745 if seconds < 60 {
746 format!("{seconds}s")
747 } else if seconds < 3_600 {
748 format!("{}m {:02}s", seconds / 60, seconds % 60)
749 } else {
750 format!("{}h {:02}m", seconds / 3_600, seconds / 60 % 60)
751 }
752}
753
754pub(super) fn initial_widgets(catalog: &UiCatalog) -> BTreeMap<(String, String), FrontendWidget> {
755 catalog
756 .widgets()
757 .map(|(middleware, item)| {
758 let mut item = item.clone();
759 item.text = bounded_terminal_text(&item.text, 32 * 1024);
760 ((middleware.to_string(), item.id.clone()), item)
761 })
762 .collect()
763}
764
765pub(super) fn widget_status(widgets: &BTreeMap<(String, String), FrontendWidget>) -> String {
766 widgets
767 .values()
768 .map(|item| format!(" · {}", item.text))
769 .collect()
770}
771
772fn render_picker_menu(frame: &mut Frame<'_>, area: Rect, picker: &super::PickerState) {
773 let areas = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
774 frame.render_widget(
775 Paragraph::new(Line::styled(
776 format!(" {}", picker.title),
777 current().style(Role::Accent).add_modifier(Modifier::BOLD),
778 )),
779 areas[0],
780 );
781 let items = picker
782 .options
783 .iter()
784 .map(|option| {
785 let description = if option.detail.is_empty() {
786 option.description.clone()
787 } else {
788 format!("{} · {}", option.description, option.detail)
789 };
790 MenuItem {
791 value: String::new(),
792 label: terminal_text(&option.label),
793 description: terminal_text(&description),
794 }
795 })
796 .collect::<Vec<_>>();
797 render_menu(frame, areas[1], &items, picker.selected);
798}
799
800fn render_menu(frame: &mut Frame<'_>, area: Rect, items: &[MenuItem], selected: usize) {
801 let theme = current();
802 if items.is_empty() {
803 frame.render_widget(
804 Paragraph::new(Line::styled(
805 " no matches",
806 theme.style(Role::Muted).add_modifier(Modifier::ITALIC),
807 )),
808 area,
809 );
810 return;
811 }
812 let name_width = items
813 .iter()
814 .map(|item| item.label.chars().count())
815 .max()
816 .unwrap_or_default();
817 let start = selected.saturating_add(1).saturating_sub(MAX_MENU_ROWS);
818 let lines = items
819 .iter()
820 .enumerate()
821 .skip(start)
822 .take(MAX_MENU_ROWS)
823 .map(|(index, item)| {
824 let is_selected = index == selected;
825 let style = if is_selected {
826 theme.style(Role::Selection)
827 } else {
828 theme.style(Role::Text)
829 };
830 let description_style = if is_selected {
831 style
832 } else {
833 theme.style(Role::Muted)
834 };
835 Line::from(vec![
836 Span::styled(if is_selected { "› " } else { " " }, style),
837 Span::styled(
838 format!(
839 "{:<width$}",
840 terminal_text(&item.label),
841 width = name_width + 2
842 ),
843 style,
844 ),
845 Span::styled(terminal_text(&item.description), description_style),
846 ])
847 })
848 .collect::<Vec<_>>();
849 frame.render_widget(Paragraph::new(lines), area);
850}
851
852pub fn terminal_text(value: &str) -> String {
853 value
854 .chars()
855 .filter(|character| matches!(character, '\n' | '\t') || !character.is_control())
856 .collect()
857}
858
859pub(super) fn bounded_terminal_text(value: &str, limit: usize) -> String {
860 let mut value = terminal_text(value);
861 if value.len() <= limit {
862 return value;
863 }
864 let mut end = limit;
865 while !value.is_char_boundary(end) {
866 end -= 1;
867 }
868 value.truncate(end);
869 value.push_str("\n[display truncated]");
870 value
871}
872
873fn display_value(value: &str) -> String {
874 terminal_text(value).chars().take(120).collect()
875}