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