Skip to main content

horus_cli/frontend/tui/
view.rs

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}