Skip to main content

mobius_cli/frontend/tui/
view.rs

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