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