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    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}